From d83d4fb7ab3ae48fdf813adf35d1024be41ccae6 Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 11 Nov 2025 16:49:27 +0200 Subject: [PATCH 1/7] feat(ui5-li-custom): implement F7 keyboard navigation F7 key enables navigation between list item and internal focusable elements: - If focus is on item level, moves focus to previously focused internal element (or first if none) - If focus is on internal element, saves focus position and moves back to item level - Add Cypress tests for F7 functionality - Add test page for manual F7 validation Jira: BGSOFUIPIRIN-6942 Related: #11987 --- packages/main/cypress/specs/List.cy.tsx | 67 ++++++++++++++ packages/main/src/ListItem.ts | 58 ++++++++++-- packages/main/src/ListItemCustom.ts | 8 +- .../main/test/pages/ListItemCustomF7.html | 92 +++++++++++++++++++ 4 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 packages/main/test/pages/ListItemCustomF7.html diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index 7b50bb97b3bc..ea6b396bc457 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -1286,6 +1286,73 @@ describe("List Tests", () => { cy.get("[ui5-li-custom]").first().should("be.focused"); }); + it("keyboard handling on F7", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-li-custom]").click(); + cy.get("[ui5-li-custom]").should("be.focused"); + + // F7 goes to first focusable element + cy.realPress("F7"); + cy.get("[ui5-button]").first().should("be.focused"); + + // Tab to second button + cy.realPress("Tab"); + cy.get("[ui5-button]").last().should("be.focused"); + + // F7 returns to list item + cy.realPress("F7"); + cy.get("[ui5-li-custom]").should("be.focused"); + + // F7 remembers last focused element (second button) + cy.realPress("F7"); + cy.get("[ui5-button]").last().should("be.focused"); + }); + + it("keyboard handling on F7 after TAB navigation", () => { + cy.mount( +
+ + + + + + + +
+ ); + + cy.get("button").click(); + cy.get("button").should("be.focused"); + + // Tab into list item + cy.realPress("Tab"); + cy.get("[ui5-li-custom]").should("be.focused"); + + // Tab into internal elements (goes to first button) + cy.realPress("Tab"); + cy.get("[ui5-button]").first().should("be.focused"); + + // Tab to second button + cy.realPress("Tab"); + cy.get("[ui5-button]").last().should("be.focused"); + + // F7 should store current element and return to list item + cy.realPress("F7"); + cy.get("[ui5-li-custom]").should("be.focused"); + + // F7 should remember the second button (not go to first) + cy.realPress("F7"); + cy.get("[ui5-button]").last().should("be.focused"); + }); + it("keyboard handling on TAB when 2 level nested UI5Element is focused", () => { cy.mount(
diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index ee24d52adc80..c00d9c15732b 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -1,6 +1,6 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import { - isSpace, isEnter, isDelete, isF2, + isSpace, isEnter, isDelete, isF2, isF7, } from "@ui5/webcomponents-base/dist/Keys.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; @@ -200,6 +200,12 @@ abstract class ListItem extends ListItemBase { @property() mediaRange = "S"; + /** + * Stores the last focused element within the list item when navigating with F7. + * @private + */ + _lastInnerFocusedElement?: HTMLElement; + /** * Defines the delete button, displayed in "Delete" mode. * **Note:** While the slot allows custom buttons, to match @@ -255,7 +261,7 @@ abstract class ListItem extends ListItemBase { document.removeEventListener("touchend", this.deactivate); } - async _onkeydown(e: KeyboardEvent) { + _onkeydown(e: KeyboardEvent) { if ((isSpace(e) || isEnter(e)) && this._isTargetSelfFocusDomRef(e)) { return; } @@ -270,15 +276,11 @@ abstract class ListItem extends ListItemBase { } if (isF2(e)) { - const activeElement = getActiveElement(); - const focusDomRef = this.getFocusDomRef()!; + this._handleF2(); + } - if (activeElement === focusDomRef) { - const firstFocusable = await getFirstFocusableElement(focusDomRef); - firstFocusable?.focus(); - } else { - focusDomRef.focus(); - } + if (isF7(e)) { + this._handleF7(e); } } @@ -518,6 +520,42 @@ abstract class ListItem extends ListItemBase { get _listItem() { return this.shadowRoot!.querySelector("li"); } + + async _handleF7(e: KeyboardEvent) { + e.preventDefault(); // Prevent browser default behavior (F7 = Caret Browsing toggle) + + const focusDomRef = this.getFocusDomRef()!; + const activeElement = getActiveElement(); + + if (activeElement === focusDomRef) { + // On list item - restore to stored element or go to first focusable + if (this._lastInnerFocusedElement) { + this._lastInnerFocusedElement.focus(); + } else { + const firstFocusable = await getFirstFocusableElement(focusDomRef); + firstFocusable?.focus(); + this._lastInnerFocusedElement = firstFocusable || undefined; + } + } else { + // On internal element - store it and go back to list item + this._lastInnerFocusedElement = activeElement as HTMLElement; + focusDomRef.focus(); + } + } + + async _handleF2() { + const focusDomRef = this.getFocusDomRef()!; + const activeElement = getActiveElement(); + + if (activeElement === focusDomRef) { + // On list item - always go to first focusable (no memory) + const firstFocusable = await getFirstFocusableElement(focusDomRef); + firstFocusable?.focus(); + } else { + // On internal element - go back to list item + focusDomRef.focus(); + } + } } export default ListItem; diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index 55a3d929ce60..ae563c5dea99 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -1,4 +1,6 @@ -import { isTabNext, isTabPrevious, isF2 } from "@ui5/webcomponents-base/dist/Keys.js"; +import { + isTabNext, isTabPrevious, isF2, isF7, +} from "@ui5/webcomponents-base/dist/Keys.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; @@ -58,7 +60,7 @@ class ListItemCustom extends ListItem { const isTab = isTabNext(e) || isTabPrevious(e); const isFocused = this.matches(":focus"); - if (!isTab && !isFocused && !isF2(e)) { + if (!isTab && !isFocused && !isF2(e) && !isF7(e)) { return; } @@ -69,7 +71,7 @@ class ListItemCustom extends ListItem { const isTab = isTabNext(e) || isTabPrevious(e); const isFocused = this.matches(":focus"); - if (!isTab && !isFocused && !isF2(e)) { + if (!isTab && !isFocused && !isF2(e) && !isF7(e)) { return; } diff --git a/packages/main/test/pages/ListItemCustomF7.html b/packages/main/test/pages/ListItemCustomF7.html new file mode 100644 index 000000000000..41c04a6e73f8 --- /dev/null +++ b/packages/main/test/pages/ListItemCustomF7.html @@ -0,0 +1,92 @@ + + + + + + + F7/F2 Key Test + + + + + +
+

F7/F2 Key Test

+

F7 vs F2 Behavior:

+
    +
  • F2: Simple navigation - always goes to first focusable element
  • +
  • F7: Smart navigation - remembers last focused element
  • +
+ +

Test Steps:

+
    +
  1. Click on a list item
  2. +
  3. Press F7 → should go to first button
  4. +
  5. Press TAB to move to second button
  6. +
  7. Press F7 → should return to list item
  8. +
  9. Press F7 again → should return to second button (memory working)
  10. +
  11. Test F2 → should always go to first button (no memory)
  12. +
+
+ + + +
+ First Button + Second Button + +
+
+ +
+ Button A + Button B + +
+
+
+ + + + + \ No newline at end of file From be0c2eee651e4e860b2c6a133b9fc969c133723f Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 11 Nov 2025 17:04:00 +0200 Subject: [PATCH 2/7] fix: remove unnecessary async/await from _onkeydown methods Fixes TypeScript linter errors for awaiting non-Promise parent calls --- packages/main/src/ListItemCustom.ts | 4 ++-- packages/main/src/TreeItemBase.ts | 4 ++-- packages/main/src/TreeItemCustom.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index ae563c5dea99..f581290f16c3 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -56,7 +56,7 @@ class ListItemCustom extends ListItem { @property() declare accessibleName?: string; - async _onkeydown(e: KeyboardEvent) { + _onkeydown(e: KeyboardEvent) { const isTab = isTabNext(e) || isTabPrevious(e); const isFocused = this.matches(":focus"); @@ -64,7 +64,7 @@ class ListItemCustom extends ListItem { return; } - await super._onkeydown(e); + super._onkeydown(e); } _onkeyup(e: KeyboardEvent) { diff --git a/packages/main/src/TreeItemBase.ts b/packages/main/src/TreeItemBase.ts index 01f888782388..274dae32e5fb 100644 --- a/packages/main/src/TreeItemBase.ts +++ b/packages/main/src/TreeItemBase.ts @@ -313,8 +313,8 @@ class TreeItemBase extends ListItem { this.fireDecoratorEvent("toggle", { item: this }); } - async _onkeydown(e: KeyboardEvent) { - await super._onkeydown(e); + _onkeydown(e: KeyboardEvent) { + super._onkeydown(e); if (!this._fixed && this.showToggleButton && isRight(e)) { if (!this.expanded) { diff --git a/packages/main/src/TreeItemCustom.ts b/packages/main/src/TreeItemCustom.ts index 68d5b65b7191..c24d2dff598d 100644 --- a/packages/main/src/TreeItemCustom.ts +++ b/packages/main/src/TreeItemCustom.ts @@ -57,7 +57,7 @@ class TreeItemCustom extends TreeItemBase { @slot() content!: Array; - async _onkeydown(e: KeyboardEvent) { + _onkeydown(e: KeyboardEvent) { if (isDown(e) && this.content?.some(el => el.contains(e.target as Node))) { e.stopPropagation(); return; @@ -69,7 +69,7 @@ class TreeItemCustom extends TreeItemBase { return; } - await super._onkeydown(e); + super._onkeydown(e); } _onkeyup(e: KeyboardEvent) { From 9ec7101fba6d80f0e0eeab44825b4947dec0e84f Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 18 Nov 2025 15:30:43 +0200 Subject: [PATCH 3/7] feat(ui5-li-custom): maintain focus position with F7 across list items F7 navigation now remembers the focused element position when moving between list items. Pressing F7 focuses the element at the same index that was previously focused in another item. The List component stores a shared _lastFocusedElementIndex property, and ListItem uses getTabbableElements to reliably find focusable elements. Helper methods handle focusing by index and updating the stored position. --- packages/main/cypress/specs/List.cy.tsx | 41 +++++++++++ packages/main/src/List.ts | 1 + packages/main/src/ListItem.ts | 68 +++++++++++++------ .../main/test/pages/ListItemCustomF7.html | 9 +-- 4 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index ea6b396bc457..8effeab5314c 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -1353,6 +1353,47 @@ describe("List Tests", () => { cy.get("[ui5-button]").last().should("be.focused"); }); + it("keyboard handling on F7 maintains focus position across list items", () => { + cy.mount( + + + + + + + + + + + + + ); + + // Focus first list item + cy.get("[ui5-li-custom]").first().click(); + cy.get("[ui5-li-custom]").first().should("be.focused"); + + // F7 to enter (should go to first button) + cy.realPress("F7"); + cy.get("[ui5-button]").eq(0).should("be.focused"); + + // Tab to second button + cy.realPress("Tab"); + cy.get("[ui5-button]").eq(1).should("be.focused"); + + // F7 to exit back to list item + cy.realPress("F7"); + cy.get("[ui5-li-custom]").first().should("be.focused"); + + // Navigate to second list item with ArrowDown + cy.realPress("ArrowDown"); + cy.get("[ui5-li-custom]").last().should("be.focused"); + + // F7 should focus the second button (same index as previous item) + cy.realPress("F7"); + cy.get("[ui5-button]").eq(4).should("be.focused").and("contain", "Item 2 - Second"); + }); + it("keyboard handling on TAB when 2 level nested UI5Element is focused", () => { cy.mount(
diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index 9ef8a7e49ebd..e419bbf8f230 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -534,6 +534,7 @@ class List extends UI5Element { _beforeElement?: HTMLElement | null; _afterElement?: HTMLElement | null; _startMarkerOutOfView: boolean = false; + _lastFocusedElementIndex?: number; handleResizeCallback: ResizeObserverCallback; onItemFocusedBound: (e: CustomEvent) => void; diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index c00d9c15732b..995f3f7c7027 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -6,6 +6,7 @@ import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import { getFirstFocusableElement } from "@ui5/webcomponents-base/dist/util/FocusableElements.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; import type { AccessibilityAttributes, AriaRole, AriaHasPopup } from "@ui5/webcomponents-base"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; @@ -21,6 +22,7 @@ import ListItemBase from "./ListItemBase.js"; import type RadioButton from "./RadioButton.js"; import type CheckBox from "./CheckBox.js"; import type { IButton } from "./Button.js"; +import type List from "./List.js"; import { DELETE, ARIA_LABEL_LIST_ITEM_CHECKBOX, @@ -200,12 +202,6 @@ abstract class ListItem extends ListItemBase { @property() mediaRange = "S"; - /** - * Stores the last focused element within the list item when navigating with F7. - * @private - */ - _lastInnerFocusedElement?: HTMLElement; - /** * Defines the delete button, displayed in "Delete" mode. * **Note:** While the slot allows custom buttons, to match @@ -521,24 +517,23 @@ abstract class ListItem extends ListItemBase { return this.shadowRoot!.querySelector("li"); } - async _handleF7(e: KeyboardEvent) { - e.preventDefault(); // Prevent browser default behavior (F7 = Caret Browsing toggle) + _getList(): List | null { + return this.closest("[ui5-list]"); + } + + _handleF7(e: KeyboardEvent) { + e.preventDefault(); const focusDomRef = this.getFocusDomRef()!; const activeElement = getActiveElement(); + const list = this._getList(); if (activeElement === focusDomRef) { - // On list item - restore to stored element or go to first focusable - if (this._lastInnerFocusedElement) { - this._lastInnerFocusedElement.focus(); - } else { - const firstFocusable = await getFirstFocusableElement(focusDomRef); - firstFocusable?.focus(); - this._lastInnerFocusedElement = firstFocusable || undefined; - } + this._focusInternalElement(list); } else { - // On internal element - store it and go back to list item - this._lastInnerFocusedElement = activeElement as HTMLElement; + if (activeElement) { + this._updateStoredFocusIndex(list, activeElement as HTMLElement); + } focusDomRef.focus(); } } @@ -548,14 +543,47 @@ abstract class ListItem extends ListItemBase { const activeElement = getActiveElement(); if (activeElement === focusDomRef) { - // On list item - always go to first focusable (no memory) const firstFocusable = await getFirstFocusableElement(focusDomRef); firstFocusable?.focus(); } else { - // On internal element - go back to list item focusDomRef.focus(); } } + + _getFocusableElements(): HTMLElement[] { + const focusDomRef = this.getFocusDomRef()!; + return getTabbableElements(focusDomRef); + } + + _focusInternalElement(list: List | null) { + const focusables = this._getFocusableElements(); + if (!focusables.length) { + return; + } + + const targetIndex = list?._lastFocusedElementIndex ?? 0; + const safeIndex = Math.min(targetIndex, focusables.length - 1); + const elementToFocus = focusables[safeIndex]; + + elementToFocus.focus(); + + if (list) { + list._lastFocusedElementIndex = safeIndex; + } + } + + _updateStoredFocusIndex(list: List | null, activeElement: HTMLElement) { + if (!list) { + return; + } + + const focusables = this._getFocusableElements(); + const currentIndex = focusables.indexOf(activeElement); + + if (currentIndex !== -1) { + list._lastFocusedElementIndex = currentIndex; + } + } } export default ListItem; diff --git a/packages/main/test/pages/ListItemCustomF7.html b/packages/main/test/pages/ListItemCustomF7.html index 41c04a6e73f8..a15d6f950b9f 100644 --- a/packages/main/test/pages/ListItemCustomF7.html +++ b/packages/main/test/pages/ListItemCustomF7.html @@ -38,21 +38,22 @@

F7/F2 Key Test

F7 vs F2 Behavior:

  • F2: Simple navigation - always goes to first focusable element
  • -
  • F7: Smart navigation - remembers last focused element
  • +
  • F7: Smart navigation - remembers last focused element position across items

Test Steps:

    -
  1. Click on a list item
  2. +
  3. Click on first list item
  4. Press F7 → should go to first button
  5. Press TAB to move to second button
  6. Press F7 → should return to list item
  7. -
  8. Press F7 again → should return to second button (memory working)
  9. +
  10. Press ArrowDown → should go to second list item
  11. +
  12. Press F7 → should go to SECOND button (maintains position!)
  13. Test F2 → should always go to first button (no memory)
- +
First Button From 3f3a76137619e28a8b7a4d67befc11ba5fa8cbea Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Thu, 20 Nov 2025 14:37:37 +0200 Subject: [PATCH 4/7] feat(ui5-li-custom): implement arrow key navigation for internal elements Arrow Up/Down keys now navigate between same-index focusable elements across list items when focus is inside the internal content of list items. This allows users to navigate column-wise through structured list items. Key features: - Navigate between corresponding elements across items (e.g., first button to first button in next/previous item) - Automatically skip items without focusable elements - Works across ui5-li-group boundaries - Preserves existing F2/F7/Tab navigation - Only prevents default scroll when List handles the event (growing button) --- packages/main/src/List.ts | 11 ++++--- packages/main/src/ListItem.ts | 48 +++++++++++++++++++++++++++-- packages/main/src/ListItemCustom.ts | 24 ++++++++------- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index e419bbf8f230..da50af4101af 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -989,8 +989,9 @@ class List extends UI5Element { } if (isDown(e)) { - this._handleDown(); - e.preventDefault(); + if (this._handleDown()) { + e.preventDefault(); + } return; } @@ -1169,10 +1170,10 @@ class List extends UI5Element { _handleDown() { if (!this.growsWithButton) { - return; + return false; } - this._shouldFocusGrowingButton(); + return this._shouldFocusGrowingButton(); } _onfocusin(e: FocusEvent) { @@ -1351,7 +1352,9 @@ class List extends UI5Element { if (currentIndex !== -1 && currentIndex === lastIndex) { this.focusGrowingButton(); + return true; } + return false; } getGrowingButton() { diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index 995f3f7c7027..c99eef8d3dbd 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -1,6 +1,6 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import { - isSpace, isEnter, isDelete, isF2, isF7, + isSpace, isEnter, isDelete, isF2, isF7, isUp, isDown, } from "@ui5/webcomponents-base/dist/Keys.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; @@ -258,10 +258,23 @@ abstract class ListItem extends ListItemBase { } _onkeydown(e: KeyboardEvent) { - if ((isSpace(e) || isEnter(e)) && this._isTargetSelfFocusDomRef(e)) { + const isInternalElementFocused = this._isTargetSelfFocusDomRef(e); + + if ((isSpace(e) || isEnter(e)) && isInternalElementFocused) { return; } + // Handle Arrow Up/Down navigation between internal elements + const isArrowKey = isUp(e) || isDown(e); + + if (isInternalElementFocused && isArrowKey) { + const offset = isUp(e) ? -1 : 1; + if (this._navigateToAdjacentItem(offset)) { + e.preventDefault(); + return; + } + } + super._onkeydown(e); const itemActive = this.type === ListItemType.Active, @@ -584,6 +597,37 @@ abstract class ListItem extends ListItemBase { list._lastFocusedElementIndex = currentIndex; } } + + _navigateToAdjacentItem(offset: -1 | 1): boolean { + const list = this._getList(); + if (!list) { + return false; + } + + const focusables = this._getFocusableElements(); + const currentElementIndex = focusables.indexOf(getActiveElement() as HTMLElement); + if (currentElementIndex === -1) { + return false; + } + + const allItems = list.getItems().filter(item => "hasConfigurableMode" in item && item.hasConfigurableMode) as ListItem[]; + let itemIndex = allItems.indexOf(this as ListItem) + offset; + + while (itemIndex >= 0 && itemIndex < allItems.length) { + const targetFocusables = allItems[itemIndex]._getFocusableElements(); + + if (targetFocusables.length > 0) { + const elementIndex = Math.min(currentElementIndex, targetFocusables.length - 1); + targetFocusables[elementIndex].focus(); + list._lastFocusedElementIndex = elementIndex; + return true; + } + + itemIndex += offset; + } + + return false; + } } export default ListItem; diff --git a/packages/main/src/ListItemCustom.ts b/packages/main/src/ListItemCustom.ts index f581290f16c3..8925892bf181 100644 --- a/packages/main/src/ListItemCustom.ts +++ b/packages/main/src/ListItemCustom.ts @@ -1,5 +1,5 @@ import { - isTabNext, isTabPrevious, isF2, isF7, + isTabNext, isTabPrevious, isF2, isF7, isUp, isDown, } from "@ui5/webcomponents-base/dist/Keys.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js"; @@ -57,25 +57,27 @@ class ListItemCustom extends ListItem { declare accessibleName?: string; _onkeydown(e: KeyboardEvent) { - const isTab = isTabNext(e) || isTabPrevious(e); const isFocused = this.matches(":focus"); + const shouldHandle = isFocused + || isTabNext(e) || isTabPrevious(e) + || isF2(e) || isF7(e) + || isUp(e) || isDown(e); - if (!isTab && !isFocused && !isF2(e) && !isF7(e)) { - return; + if (shouldHandle) { + super._onkeydown(e); } - - super._onkeydown(e); } _onkeyup(e: KeyboardEvent) { - const isTab = isTabNext(e) || isTabPrevious(e); const isFocused = this.matches(":focus"); + const shouldHandle = isFocused + || isTabNext(e) || isTabPrevious(e) + || isF2(e) || isF7(e) + || isUp(e) || isDown(e); - if (!isTab && !isFocused && !isF2(e) && !isF7(e)) { - return; + if (shouldHandle) { + super._onkeyup(e); } - - super._onkeyup(e); } get classes(): ClassMap { From 4989a78d77c7663b779ab3677a31c12c8a46333c Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Thu, 20 Nov 2025 16:15:25 +0200 Subject: [PATCH 5/7] test(ui5-li-custom): add arrow navigation test page Add comprehensive test page for arrow key navigation between internal elements across list items. Includes 8 test examples covering basic navigation, mixed items, groups, boundary cases, selection modes, and nested lists. --- .../pages/ListItemCustomArrowNavigation.html | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 packages/main/test/pages/ListItemCustomArrowNavigation.html diff --git a/packages/main/test/pages/ListItemCustomArrowNavigation.html b/packages/main/test/pages/ListItemCustomArrowNavigation.html new file mode 100644 index 000000000000..445ddfacc9b5 --- /dev/null +++ b/packages/main/test/pages/ListItemCustomArrowNavigation.html @@ -0,0 +1,308 @@ + + + + + + + List Item Custom Arrow Navigation Test + + + + + +
+

List Item Custom Arrow Navigation Test

+

Feature: Navigate between same-index focusable elements across list items.

+ +

How to Test:

+
    +
  1. Use F7 to enter internal navigation mode (focus on first focusable element)
  2. +
  3. Press Arrow Down → should move to the same element position in the next item
  4. +
  5. Press Arrow Up → should move to the same element position in the previous item
  6. +
  7. If target item has fewer elements, focuses the last available element at that position
  8. +
  9. At boundaries (first/last item), arrow keys do nothing
  10. +
+ +

Expected Behavior:

+
    +
  • ✅ Arrow navigation works across custom list items with focusable content
  • +
  • ✅ Automatically skips standard list items (no focusable elements)
  • +
  • ✅ Works across group boundaries (navigates into/out of groups)
  • +
  • ✅ Maintains element index position across items
  • +
  • ✅ Does nothing at list boundaries (first item + Up, last item + Down)
  • +
  • ✅ Browser scroll works when navigation doesn't handle the key
  • +
+
+ +
+

Example 1: Basic Column Navigation

+ + +
+ Link 1 + Button 1 + +
+
+ +
+ Link 2 + Button 2 + +
+
+ +
+ Link 3 + Button 3 + +
+
+
+

Test: Focus Link 1 → Arrow Down → should focus Link 2 (same column)

+
+ +
+

Example 2: Mixed Items (Skip Standard Items)

+ + +
+ Custom Link 1 + Custom Button 1 +
+
+ Standard Item 1 (no focusable content) + Standard Item 2 (no focusable content) + +
+ Custom Link 2 + Custom Button 2 +
+
+ +
+ Custom Link 3 + Custom Button 3 +
+
+
+

Test: Focus Custom Link 1 → Arrow Down → should skip standard items and focus Custom Link 2

+
+ +
+

Example 3: Navigation Across Groups

+ + +
+ Before Group + Button +
+
+ + +
+ Group 1 Link 1 + Group 1 Button 1 +
+
+ +
+ Group 1 Link 2 + Group 1 Button 2 +
+
+
+ + +
+ Group 2 Link 1 + Group 2 Button 1 +
+
+
+ +
+ After Group + Button +
+
+
+

Test: Arrow navigation should work seamlessly across group boundaries

+
+ +
+

Example 4: Different Number of Elements

+ + +
+ Link A + Button A + + Extra A +
+
+ +
+ Link B + Button B +
+
+ +
+ Link C + Button C + +
+
+
+

Test: Focus "Extra A" (4th element) → Arrow Down → should focus "Button B" (last available element)

+
+ +
+

Example 5: Boundary Conditions

+ + +
+ First Item Link + First Item Button +
+
+ +
+ Middle Item Link + Middle Item Button +
+
+ +
+ Last Item Link + Last Item Button +
+
+
+

Test: Focus "First Item Link" → Arrow Up → should do nothing (at top boundary)

+

Test: Focus "Last Item Button" → Arrow Down → should do nothing (at bottom boundary)

+
+ +
+

Example 6: Complex Real-World Scenario

+ + +
+ Opportunity 1 + Status: Open + Edit + Delete +
+
+ Standard separator + +
+ Opportunity 2 + Status: Closed + Edit + Delete +
+
+ + +
+ Opportunity 3 + Status: Archived + Restore +
+
+
+
+

Test: Focus "Edit" button in Opportunity 1 → Arrow Down → should skip standard item and focus "Edit" in Opportunity 2

+
+ +
+

Example 7: Selection Modes

+
+ Selection Mode: + + None + Single + SingleStart + SingleEnd + Multiple + Delete + +
+ + +
+ Product A + Price: $100 + View + Add to Cart +
+
+ +
+ Product B + Price: $200 + View + Add to Cart +
+
+ +
+ Product C + Price: $150 + View + Add to Cart +
+
+
+

Test: Arrow navigation should work regardless of selection mode

+

Test: In Multiple mode, checkbox doesn't interfere with column navigation

+

Test: In Delete mode, delete button doesn't interfere with navigation

+
+ + + + + From 363e871c496358a77e2bf95ac86112d53977ecc1 Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Thu, 20 Nov 2025 16:52:16 +0200 Subject: [PATCH 6/7] test(ui5-list): add arrow navigation tests for custom items Add cypress tests for arrow key navigation between internal elements across list items. Tests cover basic navigation, skipping standard items, group boundaries, varying element counts, and boundary conditions. --- packages/main/cypress/specs/List.cy.tsx | 183 +++++++++++++++++++++++- 1 file changed, 180 insertions(+), 3 deletions(-) diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index 8effeab5314c..46e608d738eb 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -1296,7 +1296,7 @@ describe("List Tests", () => { ); - cy.get("[ui5-li-custom]").click(); + cy.get("[ui5-li-custom]").realClick(); cy.get("[ui5-li-custom]").should("be.focused"); // F7 goes to first focusable element @@ -1329,7 +1329,7 @@ describe("List Tests", () => {
); - cy.get("button").click(); + cy.get("button").realClick(); cy.get("button").should("be.focused"); // Tab into list item @@ -1370,7 +1370,7 @@ describe("List Tests", () => { ); // Focus first list item - cy.get("[ui5-li-custom]").first().click(); + cy.get("[ui5-li-custom]").first().realClick(); cy.get("[ui5-li-custom]").first().should("be.focused"); // F7 to enter (should go to first button) @@ -1394,6 +1394,183 @@ describe("List Tests", () => { cy.get("[ui5-button]").eq(4).should("be.focused").and("contain", "Item 2 - Second"); }); + it("arrow down navigates to same-index element in next custom item", () => { + cy.mount( + + + + + + + + + + + + + + + ); + + // Focus first button in first item + cy.get("[ui5-button]").first().realClick(); + cy.get("[ui5-button]").first().should("be.focused"); + + // Arrow down should move to first button in second item + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").eq(2).should("be.focused").and("contain", "Item 2 - First"); + + // Arrow down again should move to first button in third item + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").eq(4).should("be.focused").and("contain", "Item 3 - First"); + }); + + it("arrow up navigates to same-index element in previous custom item", () => { + cy.mount( + + + + + + + + + + + + + + + ); + + // Focus second button in last item + cy.get("[ui5-button]").eq(5).realClick(); + cy.get("[ui5-button]").eq(5).should("be.focused"); + + // Arrow up should move to second button in second item + cy.realPress("ArrowUp"); + cy.get("[ui5-button]").eq(3).should("be.focused").and("contain", "Item 2 - Second"); + + // Arrow up again should move to second button in first item + cy.realPress("ArrowUp"); + cy.get("[ui5-button]").eq(1).should("be.focused").and("contain", "Item 1 - Second"); + }); + + it("arrow navigation skips standard list items", () => { + cy.mount( + + + + + Standard Item + Another Standard + + + + + ); + + // Focus button in first custom item + cy.get("[ui5-button]").first().realClick(); + cy.get("[ui5-button]").first().should("be.focused"); + + // Arrow down should skip standard items and focus button in second custom item + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").last().should("be.focused").and("contain", "Custom 2"); + + // Arrow up should skip standard items and return to first custom item + cy.realPress("ArrowUp"); + cy.get("[ui5-button]").first().should("be.focused").and("contain", "Custom 1"); + }); + + it("arrow navigation works across groups", () => { + cy.mount( + + + + + + + + + + + + + + + + + + + ); + + // Focus button before groups + cy.get("[ui5-button]").first().realClick(); + + // Navigate down through groups + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").eq(1).should("be.focused").and("contain", "In Group 1"); + + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").eq(2).should("be.focused").and("contain", "In Group 2"); + + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").last().should("be.focused").and("contain", "After Group"); + }); + + it("arrow navigation handles items with different element counts", () => { + cy.mount( + + + + + + + + + + + + + ); + + // Focus fourth button (index 3) in first item + cy.get("[ui5-button]").eq(3).realClick(); + cy.get("[ui5-button]").eq(3).should("be.focused"); + + // Arrow down should focus last button in second item (index clamped to 1) + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").eq(5).should("be.focused").and("contain", "Item 2 - B"); + }); + + it("arrow navigation does nothing at list boundaries", () => { + cy.mount( + + + + + + + + + ); + + // Focus first button + cy.get("[ui5-button]").first().realClick(); + + // Arrow up should do nothing (at top boundary) + cy.realPress("ArrowUp"); + cy.get("[ui5-button]").first().should("be.focused"); + + // Focus last button + cy.get("[ui5-button]").last().realClick(); + + // Arrow down should do nothing (at bottom boundary) + cy.realPress("ArrowDown"); + cy.get("[ui5-button]").last().should("be.focused"); + }); + it("keyboard handling on TAB when 2 level nested UI5Element is focused", () => { cy.mount(
From 7d958d05183ef32e5ece052343a1666e3bb8e1be Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Mon, 24 Nov 2025 17:42:12 +0200 Subject: [PATCH 7/7] fix(ui5-li): only handle F7/F2 when tabbable elements exist F7 and F2 navigation now checks if there are tabbable elements inside the list item before handling the event. If no tabbable elements exist (e.g., delete button with tabindex="-1"), the event propagates to allow parent components like Tokenizer to handle it with their custom logic. This fixes interference with Tokenizer's F7 behavior while maintaining correct navigation for custom list items. --- packages/main/src/ListItem.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index c99eef8d3dbd..48a038d67ec6 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -535,12 +535,17 @@ abstract class ListItem extends ListItemBase { } _handleF7(e: KeyboardEvent) { - e.preventDefault(); - const focusDomRef = this.getFocusDomRef()!; const activeElement = getActiveElement(); const list = this._getList(); + const focusables = this._getFocusableElements().length > 0; + if (!focusables) { + return; + } + + e.preventDefault(); + if (activeElement === focusDomRef) { this._focusInternalElement(list); } else { @@ -555,6 +560,11 @@ abstract class ListItem extends ListItemBase { const focusDomRef = this.getFocusDomRef()!; const activeElement = getActiveElement(); + const focusables = this._getFocusableElements().length > 0; + if (!focusables) { + return; + } + if (activeElement === focusDomRef) { const firstFocusable = await getFirstFocusableElement(focusDomRef); firstFocusable?.focus();