diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index 7b50bb97b3bc..46e608d738eb 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -1286,6 +1286,291 @@ describe("List Tests", () => { cy.get("[ui5-li-custom]").first().should("be.focused"); }); + it("keyboard handling on F7", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-li-custom]").realClick(); + 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").realClick(); + 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 F7 maintains focus position across list items", () => { + cy.mount( + + + + + + + + + + + + + ); + + // Focus first list item + cy.get("[ui5-li-custom]").first().realClick(); + 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("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(
diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index 9ef8a7e49ebd..da50af4101af 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; @@ -988,8 +989,9 @@ class List extends UI5Element { } if (isDown(e)) { - this._handleDown(); - e.preventDefault(); + if (this._handleDown()) { + e.preventDefault(); + } return; } @@ -1168,10 +1170,10 @@ class List extends UI5Element { _handleDown() { if (!this.growsWithButton) { - return; + return false; } - this._shouldFocusGrowingButton(); + return this._shouldFocusGrowingButton(); } _onfocusin(e: FocusEvent) { @@ -1350,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 ee24d52adc80..48a038d67ec6 100644 --- a/packages/main/src/ListItem.ts +++ b/packages/main/src/ListItem.ts @@ -1,11 +1,12 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import { - isSpace, isEnter, isDelete, isF2, + 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"; 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, @@ -255,11 +257,24 @@ abstract class ListItem extends ListItemBase { document.removeEventListener("touchend", this.deactivate); } - async _onkeydown(e: KeyboardEvent) { - if ((isSpace(e) || isEnter(e)) && this._isTargetSelfFocusDomRef(e)) { + _onkeydown(e: KeyboardEvent) { + 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, @@ -270,15 +285,11 @@ abstract class ListItem extends ListItemBase { } if (isF2(e)) { - const activeElement = getActiveElement(); - const focusDomRef = this.getFocusDomRef()!; - - if (activeElement === focusDomRef) { - const firstFocusable = await getFirstFocusableElement(focusDomRef); - firstFocusable?.focus(); - } else { - focusDomRef.focus(); - } + this._handleF2(); + } + + if (isF7(e)) { + this._handleF7(e); } } @@ -518,6 +529,115 @@ abstract class ListItem extends ListItemBase { get _listItem() { return this.shadowRoot!.querySelector("li"); } + + _getList(): List | null { + return this.closest("[ui5-list]"); + } + + _handleF7(e: KeyboardEvent) { + 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 { + if (activeElement) { + this._updateStoredFocusIndex(list, activeElement as HTMLElement); + } + focusDomRef.focus(); + } + } + + async _handleF2() { + 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(); + } else { + 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; + } + } + + _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 55a3d929ce60..8925892bf181 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, 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"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; @@ -54,26 +56,28 @@ class ListItemCustom extends ListItem { @property() declare accessibleName?: string; - async _onkeydown(e: KeyboardEvent) { - const isTab = isTabNext(e) || isTabPrevious(e); + _onkeydown(e: KeyboardEvent) { 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)) { - return; + if (shouldHandle) { + super._onkeydown(e); } - - await 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)) { - return; + if (shouldHandle) { + super._onkeyup(e); } - - super._onkeyup(e); } get classes(): ClassMap { 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) { 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

+
+ + + + + diff --git a/packages/main/test/pages/ListItemCustomF7.html b/packages/main/test/pages/ListItemCustomF7.html new file mode 100644 index 000000000000..a15d6f950b9f --- /dev/null +++ b/packages/main/test/pages/ListItemCustomF7.html @@ -0,0 +1,93 @@ + + + + + + + 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 position across items
  • +
+ +

Test Steps:

+
    +
  1. Click on first 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 ArrowDown → should go to second list item
  10. +
  11. Press F7 → should go to SECOND button (maintains position!)
  12. +
  13. Test F2 → should always go to first button (no memory)
  14. +
+
+ + + +
+ First Button + Second Button + +
+
+ +
+ Button A + Button B + +
+
+
+ + + + + \ No newline at end of file