From 065090d53a0a154030cc9b907a96404d8e3018b5 Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 11 Nov 2025 16:49:27 +0200 Subject: [PATCH 1/3] 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 fad93329ab940fea1d2bcdfb0946070f75023e9d Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 11 Nov 2025 17:04:00 +0200 Subject: [PATCH 2/3] 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 da8b7925ec6e58d5fc37be6d719d6b606875ae24 Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Tue, 18 Nov 2025 15:30:43 +0200 Subject: [PATCH 3/3] 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