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..92e881c7cf2a 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -11,6 +11,7 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js"; import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import { isTabNext, isSpace, @@ -21,6 +22,7 @@ import { isHome, isDown, isUp, + isF7, } from "@ui5/webcomponents-base/dist/Keys.js"; import DragAndDropHandler from "./delegate/DragAndDropHandler.js"; import type { MoveEventDetail } from "@ui5/webcomponents-base/dist/util/dragAndDrop/DragRegistry.js"; @@ -42,11 +44,11 @@ import ListSelectionMode from "./types/ListSelectionMode.js"; import ListGrowingMode from "./types/ListGrowingMode.js"; import ListAccessibleRole from "./types/ListAccessibleRole.js"; import type ListItemBase from "./ListItemBase.js"; +import type ListItem from "./ListItem.js"; import type { ListItemBasePressEventDetail, } from "./ListItemBase.js"; import type DropIndicator from "./DropIndicator.js"; -import type ListItem from "./ListItem.js"; import type { SelectionRequestEventDetail, } from "./ListItem.js"; @@ -534,6 +536,7 @@ class List extends UI5Element { _beforeElement?: HTMLElement | null; _afterElement?: HTMLElement | null; _startMarkerOutOfView: boolean = false; + _lastFocusedElementIndex?: number; handleResizeCallback: ResizeObserverCallback; onItemFocusedBound: (e: CustomEvent) => void; @@ -987,9 +990,19 @@ class List extends UI5Element { return; } + // Handle Arrow Up/Down navigation between internal elements + const isArrowKey = isUp(e) || isDown(e); + const listItem = this._getClosestListItem(e.target as HTMLElement); + if (listItem?._isFocusOnInternalElement() && isArrowKey) { + const offset = isUp(e) ? -1 : 1; + if (this._navigateToAdjacentItem(listItem, offset)) { + e.preventDefault(); + return; + } + } + if (isDown(e)) { - this._handleDown(); - e.preventDefault(); + this._handleDown(e); return; } @@ -1001,6 +1014,35 @@ class List extends UI5Element { if (isTabNext(e)) { this._handleTabNext(e); } + + if (isF7(e)) { + this._handleF7(e); + } + } + + _handleF7(e: KeyboardEvent) { + const listItem = this._getClosestListItem(e.target as HTMLElement); + if (!listItem || !listItem._hasFocusableElements()) { + return; + } + + const listItemDomRef = listItem.getFocusDomRef()!; + const activeElement = getActiveElement(); + + e.preventDefault(); + + if (activeElement === listItemDomRef) { + listItem._focusInternalElement(this._lastFocusedElementIndex ?? 0); + this._lastFocusedElementIndex = listItem._getFocusedElementIndex(); + } else { + this._lastFocusedElementIndex = listItem._getFocusedElementIndex(); + listItemDomRef.focus(); + } + } + + _getClosestListItem(element: HTMLElement): ListItem | null { + const listItem = element.closest("[ui5-li], [ui5-li-custom]"); + return listItem; } _moveItem(item: ListItemBase, e: KeyboardEvent) { @@ -1163,15 +1205,41 @@ class List extends UI5Element { return; } - this._shouldFocusGrowingButton(); + if (this._shouldFocusGrowingButton()) { + this.focusGrowingButton(); + } } - _handleDown() { - if (!this.growsWithButton) { - return; + _handleDown(e: KeyboardEvent) { + if (this._shouldFocusGrowingButton()) { + this.focusGrowingButton(); + e.preventDefault(); } + } + + _navigateToAdjacentItem(listItem: ListItem, offset: -1 | 1): boolean { + const targetInternalElementIndex = listItem?._getFocusedElementIndex(); + if (targetInternalElementIndex === undefined || targetInternalElementIndex === -1) { + return false; + } + + const allItems = this.getItems().filter(node => { + return "hasConfigurableMode" in node && node.hasConfigurableMode + && (node as ListItem)._hasFocusableElements(); + }) as ListItem[]; + + const itemIndex = allItems.indexOf(listItem) + offset; + const nextNode = allItems[itemIndex]; - this._shouldFocusGrowingButton(); + if (!nextNode) { + return false; + } + + const focusedIndex = nextNode._focusInternalElement(targetInternalElementIndex); + if (focusedIndex !== undefined) { + this._lastFocusedElementIndex = focusedIndex; + } + return true; } _onfocusin(e: FocusEvent) { @@ -1344,13 +1412,14 @@ class List extends UI5Element { } _shouldFocusGrowingButton() { + if (!this.growsWithButton) { + return false; + } const items = this.getItems(); const lastIndex = items.length - 1; const currentIndex = this._itemNavigation._currentIndex; - if (currentIndex !== -1 && currentIndex === lastIndex) { - this.focusGrowingButton(); - } + return currentIndex !== -1 && currentIndex === lastIndex; } getGrowingButton() { diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts index ee24d52adc80..ba655edb08f0 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"; @@ -255,8 +256,10 @@ 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 = e.target !== this.getFocusDomRef(); + + if ((isSpace(e) || isEnter(e)) && isInternalElementFocused) { return; } @@ -270,15 +273,7 @@ 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(); } } @@ -345,13 +340,6 @@ abstract class ListItem extends ListItemBase { } } - _isTargetSelfFocusDomRef(e: KeyboardEvent): boolean { - const target = e.target as HTMLElement, - focusDomRef = this.getFocusDomRef(); - - return target !== focusDomRef; - } - /** * Called when selection components in Single (ui5-radio-button) * and Multi (ui5-checkbox) selection modes are used. @@ -518,6 +506,58 @@ abstract class ListItem extends ListItemBase { get _listItem() { return this.shadowRoot!.querySelector("li"); } + + 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); + } + + _getFocusedElementIndex(): number { + const focusables = this._getFocusableElements(); + const activeElement = getActiveElement() as HTMLElement; + return focusables.indexOf(activeElement); + } + + _hasFocusableElements(): boolean { + return this._getFocusableElements().length > 0; + } + + _isFocusOnInternalElement(): boolean { + const focusables = this._getFocusableElements(); + const currentElementIndex = focusables.indexOf(getActiveElement() as HTMLElement); + return currentElementIndex !== -1; + } + + _focusInternalElement(targetIndex: number) { + const focusables = this._getFocusableElements(); + if (!focusables.length) { + return; + } + + const safeIndex = Math.min(targetIndex, focusables.length - 1); + const elementToFocus = focusables[safeIndex]; + + elementToFocus.focus(); + + return safeIndex; + } } 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