diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx
index eb6dfe355402..d2b8722d1851 100644
--- a/packages/main/cypress/specs/List.cy.tsx
+++ b/packages/main/cypress/specs/List.cy.tsx
@@ -13,6 +13,7 @@ import Select from "../../src/Select.js";
import Option from "../../src/Option.js";
import CheckBox from "../../src/CheckBox.js";
import Bar from "../../src/Bar.js";
+import Link from "../../src/Link.js";
function getGrowingWithScrollList(length: number, height: string = "100px") {
return (
@@ -153,10 +154,10 @@ describe("List Tests", () => {
HP Monitor 24
);
-
+
cy.get("[ui5-list]")
.as("list");
-
+
cy.get("@list").invoke('prop', 'accessibilityAttributes', {
growingButton: {
name: "Load more products from catalog"
@@ -167,7 +168,7 @@ describe("List Tests", () => {
.shadow()
.find("[id$='growing-btn']")
.should("have.attr", "aria-label", "Load more products from catalog");
-
+
cy.get("@list")
.shadow()
.find("[id$='growing-btn']")
@@ -182,7 +183,7 @@ describe("List Tests", () => {
Product 3
);
-
+
cy.get("[ui5-list]")
.as("list");
@@ -652,22 +653,22 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
$list[0].addEventListener("ui5-item-click", cy.stub().as("itemClickStub"));
$list[0].addEventListener("ui5-selection-change", cy.stub().as("selectionChangeStub"));
});
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@itemClickStub").should("have.been.calledOnce");
cy.get("@selectionChangeStub").should("have.been.calledOnce");
-
+
cy.get("[ui5-li]").eq(1)
.shadow()
.find("ui5-radio-button")
.click();
-
+
cy.get("@itemClickStub").should("have.been.calledOnce");
cy.get("@selectionChangeStub").should("have.been.calledTwice");
cy.get("@selectionChangeStub").should("have.been.calledWith", Cypress.sinon.match.has("detail", Cypress.sinon.match.has("selectionComponentPressed", true)));
@@ -686,14 +687,14 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
$list[0].addEventListener("ui5-item-click", cy.stub().as("itemClickStub"));
$list[0].addEventListener("ui5-selection-change", cy.stub().as("selectionChangeStub"));
});
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@itemClickStub").should("have.been.calledOnce");
cy.get("@selectionChangeStub").should("have.been.calledOnce");
});
@@ -710,13 +711,13 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
$list[0].addEventListener("ui5-selection-change", cy.stub().as("selectionChangeStub"));
});
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@selectionChangeStub").should("have.been.calledOnce");
cy.get("@selectionChangeStub").should("have.been.calledWith", Cypress.sinon.match.has("detail", Cypress.sinon.match.has("previouslySelectedItems")));
cy.get("[ui5-li]").eq(1).should("exist");
@@ -730,7 +731,7 @@ describe("List Tests", () => {
China
);
-
+
cy.get("[ui5-list]").then(($list) => {
const stub = cy.stub().as("selectionChangeStub");
stub.callsFake((event) => {
@@ -738,11 +739,11 @@ describe("List Tests", () => {
});
$list[0].addEventListener("ui5-selection-change", stub);
});
-
+
cy.get("[ui5-li]").eq(2).should("have.attr", "selected");
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@selectionChangeStub").should("have.been.calledOnce");
cy.get("[ui5-li]").first().should("not.have.attr", "selected");
cy.get("[ui5-li]").eq(2).should("have.attr", "selected");
@@ -756,7 +757,7 @@ describe("List Tests", () => {
China
);
-
+
cy.get("[ui5-list]").then(($list) => {
const stub = cy.stub().as("selectionChangeStub");
stub.callsFake((event) => {
@@ -764,13 +765,13 @@ describe("List Tests", () => {
});
$list[0].addEventListener("ui5-selection-change", stub);
});
-
+
cy.get("[ui5-li]").first().should("not.have.attr", "selected");
cy.get("[ui5-li]").eq(1).should("have.attr", "selected");
cy.get("[ui5-li]").eq(2).should("have.attr", "selected");
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@selectionChangeStub").should("have.been.calledOnce");
cy.get("[ui5-li]").first().should("not.have.attr", "selected");
cy.get("[ui5-li]").eq(1).should("have.attr", "selected");
@@ -1129,35 +1130,35 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
$list[0].addEventListener("ui5-item-delete", cy.stub().as("itemDeleteStub"));
});
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.click();
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.should("not.have.attr", "selected");
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.shadow()
.find("ui5-button")
.should("exist");
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.shadow()
.find("ui5-button")
.click();
-
+
cy.get("@itemDeleteStub").should("have.been.calledOnce");
cy.get("@itemDeleteStub").should("have.been.calledWith", Cypress.sinon.match.has("detail", Cypress.sinon.match.has("item")));
});
@@ -1180,27 +1181,90 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
$list[0].addEventListener("ui5-item-delete", cy.stub().as("itemDeleteStub"));
});
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.click();
-
+
cy.get("[ui5-list]")
.find("ui5-li")
.first()
.should("not.have.attr", "selected");
-
+
cy.realPress("Delete");
-
+
cy.get("@itemDeleteStub").should("have.been.calledOnce");
cy.get("@itemDeleteStub").should("have.been.calledWith", Cypress.sinon.match.has("detail", Cypress.sinon.match.has("item")));
});
+ it("selectionMode: delete. F2 + Tab reaches delete button", () => {
+ cy.mount(
+
+
+
+ Laptop HP
+ Laptop Lenovo
+
+
+
+ );
+
+ cy.get("[ui5-list]").then(($list) => {
+ $list[0].addEventListener("ui5-item-delete", cy.stub().as("itemDeleteStub"));
+ });
+
+ // Click first item to focus it
+ cy.get("[ui5-li]").first().click();
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // Tab in navigation mode should forward out — not reach delete button
+ cy.realPress("Tab");
+ cy.get("button").last().should("be.focused");
+
+ // Re-focus item, then F2 to enter edit mode
+ cy.get("[ui5-li]").first().click();
+ cy.realPress("F2");
+ cy.get("[ui5-li]")
+ .first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Enter on the focused delete button should trigger deletion
+ cy.realPress("Enter");
+ cy.get("@itemDeleteStub").should("have.been.calledOnce");
+ });
+
+ it("selectionMode: delete. F2 toggles focus to delete button", () => {
+ cy.mount(
+
+ Laptop HP
+ Laptop Lenovo
+
+ );
+
+ // Click first item to focus it
+ cy.get("[ui5-li]").first().click();
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // F2 should move focus to the delete button
+ cy.realPress("F2");
+ cy.get("[ui5-li]")
+ .first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // F2 again should return focus to the list item
+ cy.realPress("F2");
+ cy.get("[ui5-li]").first().should("be.focused");
+ });
+
it("item size and classes, when an item has both text and description", () => {
const EXPECTED_HEIGHT = 80;
@@ -1315,16 +1379,7 @@ describe("List Tests", () => {
cy.get("[ui5-li-custom]").first().click();
cy.get("[ui5-li-custom]").first().should("be.focused");
- cy.realPress("Tab");
- cy.get("[ui5-li-custom]").first().find("button").first().should("be.focused");
-
- cy.realPress("Tab");
- cy.get("[ui5-li-custom]").first().find("a").should("be.focused");
-
- cy.realPress("Tab");
- cy.get("[ui5-li-custom]").first().find("input[type='radio']").should("be.focused");
-
- cy.get("[ui5-li]").first().click();
+ // Tab forwards out of item (navigation mode) — internal elements require F2
cy.realPress("Tab");
cy.get("[ui5-list]")
.shadow()
@@ -1382,15 +1437,15 @@ describe("List Tests", () => {
cy.get("[ui5-li-custom]").realClick();
cy.get("[ui5-li-custom]").should("be.focused");
- // F7 goes to first focusable element
+ // F7 goes to first focusable element (enters edit mode)
cy.realPress("F7");
cy.get("[ui5-button]").first().should("be.focused");
- // Tab to second button
+ // Tab to second button (edit mode allows cycling)
cy.realPress("Tab");
cy.get("[ui5-button]").last().should("be.focused");
- // F7 returns to list item
+ // F7 returns to list item (exits edit mode)
cy.realPress("F7");
cy.get("[ui5-li-custom]").should("be.focused");
@@ -1419,11 +1474,11 @@ describe("List Tests", () => {
cy.realPress("Tab");
cy.get("[ui5-li-custom]").should("be.focused");
- // Tab into internal elements (goes to first button)
- cy.realPress("Tab");
+ // F7 to enter internal elements (enables edit mode)
+ cy.realPress("F7");
cy.get("[ui5-button]").first().should("be.focused");
- // Tab to second button
+ // Tab to second button (edit mode allows cycling)
cy.realPress("Tab");
cy.get("[ui5-button]").last().should("be.focused");
@@ -1456,11 +1511,11 @@ describe("List Tests", () => {
cy.get("[ui5-li-custom]").first().realClick();
cy.get("[ui5-li-custom]").first().should("be.focused");
- // F7 to enter (should go to first button)
+ // F7 to enter (should go to first button, enables edit mode)
cy.realPress("F7");
cy.get("[ui5-button]").eq(0).should("be.focused");
- // Tab to second button
+ // Tab to second button (edit mode allows cycling)
cy.realPress("Tab");
cy.get("[ui5-button]").eq(1).should("be.focused");
@@ -1675,9 +1730,7 @@ describe("List Tests", () => {
cy.get("[ui5-li]").eq(1).click();
cy.get("[ui5-li]").eq(1).should("be.focused");
- cy.realPress("Tab");
- cy.get("ui5-breadcrumbs").should("be.focused");
-
+ // Tab forwards out of item (navigation mode) — internal Breadcrumbs requires F2
cy.realPress("Tab");
cy.get("[ui5-button]").should("be.focused");
});
@@ -1714,16 +1767,16 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-li]").first().then(($item) => {
$item[0].addEventListener("ui5-detail-click", cy.stub().as("detailClickStub"));
});
-
+
cy.get("[ui5-li]").first()
.shadow()
.find(".ui5-li-detailbtn")
.click();
-
+
cy.get("@detailClickStub").should("have.been.calledOnce");
});
@@ -1870,12 +1923,12 @@ describe("List Tests", () => {
);
-
+
const NEW_TEXT = "updated";
-
+
cy.get("[ui5-li]").first()
.should("have.prop", "innerHTML", "");
-
+
cy.get("[ui5-li]").first()
.shadow()
.find("slot")
@@ -1883,7 +1936,7 @@ describe("List Tests", () => {
const assignedNodes = ($slot[0] as any).assignedNodes();
expect(assignedNodes.length).to.equal(0);
});
-
+
cy.get("[ui5-button]").then(($btn) => {
const stub = cy.stub().as("buttonClickStub");
stub.callsFake(() => {
@@ -1894,13 +1947,13 @@ describe("List Tests", () => {
});
$btn[0].addEventListener("click", stub);
});
-
+
cy.get("[ui5-button]").click();
-
+
cy.get("@buttonClickStub").should("have.been.calledOnce");
cy.get("[ui5-li]").first()
.should("have.prop", "innerHTML", NEW_TEXT);
-
+
cy.get("[ui5-li]").first()
.shadow()
.find("slot")
@@ -1920,7 +1973,7 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-list]").then(($list) => {
const stub = cy.stub().as("itemClickStub");
stub.callsFake((event) => {
@@ -1928,9 +1981,9 @@ describe("List Tests", () => {
});
$list[0].addEventListener("ui5-item-click", stub);
});
-
+
cy.get("[ui5-li]").first().click();
-
+
cy.get("@itemClickStub").should("have.been.calledOnce");
cy.get("[ui5-li]").first().should("not.have.attr", "selected");
});
@@ -2113,18 +2166,18 @@ describe("List Tests", () => {
);
-
+
cy.get("[ui5-select]").then(($select) => {
const listItem = $select.closest("ui5-li-custom")[0];
if (listItem) {
listItem.addEventListener("ui5-item-close", cy.stub().as("itemCloseStub"));
}
});
-
+
cy.get("[ui5-select]").click();
-
+
cy.realPress("Escape");
-
+
cy.get("@itemCloseStub").should("not.have.been.called");
});
@@ -2487,14 +2540,14 @@ describe("List keyboard drag and drop tests", () => {
cy.get("[ui5-list]").then(($list) => {
const list = $list[0];
-
+
list.addEventListener("keydown", (e) => {
if (e.ctrlKey) {
const focusedItem = document.activeElement as HTMLElement;
if (!focusedItem || !focusedItem.matches("[ui5-li]")) return;
-
+
let targetItem = null;
-
+
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
@@ -2554,14 +2607,14 @@ describe("List keyboard drag and drop tests", () => {
cy.get("[ui5-list]").then(($list) => {
const list = $list[0];
-
+
list.addEventListener("keydown", (e) => {
if (e.ctrlKey) {
const focusedItem = document.activeElement as HTMLElement;
if (!focusedItem || !focusedItem.matches("[ui5-li]")) return;
-
+
let targetItem = null;
-
+
switch (e.key) {
case "ArrowUp":
targetItem = focusedItem.previousElementSibling as HTMLElement;
@@ -2625,10 +2678,10 @@ describe("List sticky header", () => {
cy.get("@header")
.then(($headerBefore) => {
const headerTopBefore = $headerBefore[0].getBoundingClientRect().top;
-
+
cy.get("@container")
.scrollTo(0, 50);
-
+
cy.get("@header")
.should(($headerAfter) => {
const headerTopAfter = $headerAfter[0].getBoundingClientRect().top;
@@ -2661,10 +2714,10 @@ describe("List sticky header", () => {
cy.get("@header")
.then(($headerBefore) => {
const headerTopBefore = $headerBefore[0].getBoundingClientRect().top;
-
+
cy.get("@container")
.scrollTo(0, 50);
-
+
cy.get("@header")
.should(($headerAfter) => {
const headerTopAfter = $headerAfter[0].getBoundingClientRect().top;
@@ -2672,4 +2725,327 @@ describe("List sticky header", () => {
});
});
});
-});
\ No newline at end of file
+});
+
+describe("Edit mode (F2) with Delete selection mode", () => {
+ it("F2 enters edit mode and Tab cycles to delete button", () => {
+ cy.mount(
+
+ Item 1
+ Item 2
+
+ );
+
+ // Focus first item
+ cy.get("[ui5-li]").first().realClick();
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // F2 enters edit mode - focus moves to first focusable element
+ cy.realPress("F2");
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // F2 again exits edit mode - focus returns to list item
+ cy.realPress("F2");
+ cy.get("[ui5-li]").first().should("be.focused");
+ });
+
+ it("Tab in non-edit delete mode forwards to next item", () => {
+ cy.mount(
+
+
+ Item 1
+ Item 2
+
+
+
+ );
+
+ // Focus first item (not in edit mode)
+ cy.get("[ui5-li]").first().realClick();
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // Tab should move focus out of list (forward-after), not to delete button
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first().should("not.be.focused");
+ });
+
+ it("Tab cycles through focusable elements in edit mode", () => {
+ cy.mount(
+
+ Item 1
+ Item 2
+
+ );
+
+ // Focus first item and enter edit mode
+ cy.get("[ui5-li]").first().realClick();
+ cy.realPress("F2");
+
+ // First focusable (delete button) should be focused
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Tab should cycle back (only one focusable element)
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+ });
+
+ it("F7 exits edit mode and returns focus to list item", () => {
+ cy.mount(
+
+ Item 1
+ Item 2
+
+ );
+
+ // Focus first item, enter edit mode via F2
+ cy.get("[ui5-li]").first().realClick();
+ cy.realPress("F2");
+
+ // Delete button should be focused
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // F7 returns focus to the list item and exits edit mode
+ cy.realPress("F7");
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // Tab should forward (not cycle), confirming edit mode is off
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first().should("not.be.focused");
+ });
+
+ it("Arrow Down/Up transfers edit mode to adjacent items", () => {
+ cy.mount(
+
+ Item 1
+ Item 2
+ Item 3
+
+ );
+
+ // Focus first item and enter edit mode
+ cy.get("[ui5-li]").first().realClick();
+ cy.realPress("F2");
+
+ // Delete button of first item should be focused
+ cy.get("[ui5-li]").eq(0)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Down moves to delete button of second item
+ cy.realPress("ArrowDown");
+ cy.get("[ui5-li]").eq(1)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Tab still cycles (edit mode was transferred) — single focusable, stays put
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").eq(1)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Down again to third item
+ cy.realPress("ArrowDown");
+ cy.get("[ui5-li]").eq(2)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Down at boundary does nothing — stays on third item
+ cy.realPress("ArrowDown");
+ cy.get("[ui5-li]").eq(2)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Up goes back to second item
+ cy.realPress("ArrowUp");
+ cy.get("[ui5-li]").eq(1)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Up to first item
+ cy.realPress("ArrowUp");
+ cy.get("[ui5-li]").eq(0)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Arrow Up at boundary does nothing — stays on first item
+ cy.realPress("ArrowUp");
+ cy.get("[ui5-li]").eq(0)
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // F2 exits edit mode from any item
+ cy.realPress("F2");
+ cy.get("[ui5-li]").eq(0).should("be.focused");
+ });
+
+ it("focus-out clears edit mode", () => {
+ cy.mount(
+
+
+ Item 1
+
+
+
+ );
+
+ // Focus item and enter edit mode
+ cy.get("[ui5-li]").first().realClick();
+ cy.realPress("F2");
+
+ // Delete button should be focused
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Click outside to move focus away
+ cy.get("button").contains("Outside").realClick();
+
+ // Re-focus the list item
+ cy.get("[ui5-li]").first().realClick();
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // Tab should forward (not cycle), confirming edit mode was cleared
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first().should("not.be.focused");
+ });
+
+ it("complete edit mode workflow with complex list items", () => {
+ // Complex list: ListItemCustom with multiple interactive elements,
+ // standard items, and a custom delete button slot
+ cy.mount(
+
+
+
+
+
+ SAP Link
+
+
+ Simple Item
+
+ Item with custom delete
+
+
+
+
+
+
+
+ );
+
+ // === Step 1: Focus first custom item ===
+ cy.get("[ui5-li-custom]").realClick();
+ cy.get("[ui5-li-custom]").should("be.focused");
+
+ // === Step 2: Enter edit mode with F2 ===
+ cy.realPress("F2");
+ // First focusable inside the custom item (Action 1 button) should be focused
+ cy.get("#action1").should("be.focused");
+
+ // === Step 3: Tab cycles through all internal elements ===
+ cy.realPress("Tab");
+ cy.get("[ui5-link]").should("be.focused");
+
+ cy.realPress("Tab");
+ cy.get("#action2").should("be.focused");
+
+ // Next Tab should reach the delete button in shadow DOM
+ cy.realPress("Tab");
+ cy.get("[ui5-li-custom]")
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Tab wraps back to first element (Action 1)
+ cy.realPress("Tab");
+ cy.get("#action1").should("be.focused");
+
+ // === Step 4: Shift+Tab cycles backward ===
+ cy.realPress(["Shift", "Tab"]);
+ cy.get("[ui5-li-custom]")
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ cy.realPress(["Shift", "Tab"]);
+ cy.get("#action2").should("be.focused");
+
+ // === Step 5: Arrow Down transfers edit mode to next item ===
+ cy.realPress("ArrowDown");
+ // Second item (ListItemStandard "Simple Item") should have its
+ // delete button focused (same positional index, clamped)
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // Tab should cycle (still in edit mode) — only 1 focusable, stays put
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // === Step 6: Arrow Down to item with custom delete button ===
+ cy.realPress("ArrowDown");
+ // Third item has a custom delete button in the light DOM slot
+ cy.get("#customDel").should("be.focused");
+
+ // === Step 7: Arrow Up goes back, preserving edit mode ===
+ cy.realPress("ArrowUp");
+ cy.get("[ui5-li]").first()
+ .shadow()
+ .find("[ui5-button]")
+ .should("be.focused");
+
+ // === Step 8: F2 exits edit mode, returns to item level ===
+ cy.realPress("F2");
+ cy.get("[ui5-li]").first().should("be.focused");
+
+ // === Step 9: Tab in non-edit delete mode forwards out ===
+ cy.realPress("Tab");
+ cy.get("[ui5-li]").first().should("not.be.focused");
+ });
+
+ it("Shift+Tab in non-edit delete mode forwards to previous item", () => {
+ cy.mount(
+
+
+
+ Item 1
+ Item 2
+
+
+
+ );
+
+ // Focus second item
+ cy.get("[ui5-li]").last().realClick();
+ cy.get("[ui5-li]").last().should("be.focused");
+
+ // Shift+Tab should move focus out of the list (forward-before)
+ cy.realPress(["Shift", "Tab"]);
+ cy.get("[ui5-li]").last().should("not.be.focused");
+ });
+});
diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts
index 4ce0a29f3332..6c29b3377e51 100644
--- a/packages/main/src/List.ts
+++ b/packages/main/src/List.ts
@@ -1067,10 +1067,12 @@ class List extends UI5Element {
e.preventDefault();
if (activeElement === listItemDomRef) {
+ listItem._editMode = true;
listItem._focusInternalElement(this._lastFocusedElementIndex ?? 0);
this._lastFocusedElementIndex = listItem._getFocusedElementIndex();
} else {
this._lastFocusedElementIndex = listItem._getFocusedElementIndex();
+ listItem._editMode = false;
listItemDomRef.focus();
}
}
@@ -1270,6 +1272,7 @@ class List extends UI5Element {
return false;
}
+ nextNode._editMode = listItem._editMode;
const focusedIndex = nextNode._focusInternalElement(targetInternalElementIndex);
if (focusedIndex !== undefined) {
this._lastFocusedElementIndex = focusedIndex;
diff --git a/packages/main/src/ListItem.ts b/packages/main/src/ListItem.ts
index f2d64f0227e1..cf85053c14ab 100644
--- a/packages/main/src/ListItem.ts
+++ b/packages/main/src/ListItem.ts
@@ -194,6 +194,16 @@ abstract class ListItem extends ListItemBase {
@property()
_selectionMode: `${ListSelectionMode}` = "None";
+ /**
+ * Indicates whether the list item is in edit mode.
+ * When active, Tab cycles through internal focusable elements
+ * instead of navigating to the next list item.
+ * Toggled by F2 or F7.
+ * @private
+ */
+ @property({ type: Boolean, noAttribute: true })
+ _editMode = false;
+
/**
* Defines the current media query size.
* @default "S"
@@ -314,6 +324,13 @@ abstract class ListItem extends ListItemBase {
}
_onfocusout(e: FocusEvent) {
+ if (this._editMode) {
+ const relatedTarget = e.relatedTarget as Node;
+ if (!relatedTarget || !(this.contains(relatedTarget) || this.shadowRoot!.contains(relatedTarget))) {
+ this._editMode = false;
+ }
+ }
+
if (e.target !== this.getFocusDomRef()) {
return;
}
@@ -518,13 +535,56 @@ abstract class ListItem extends ListItemBase {
}
if (activeElement === focusDomRef) {
+ this._editMode = true;
const firstFocusable = await getFirstFocusableElement(focusDomRef);
firstFocusable?.focus();
} else {
+ this._editMode = false;
focusDomRef.focus();
}
}
+ _handleTabNext(e: KeyboardEvent) {
+ if (this._editMode) {
+ e.preventDefault();
+ const focusables = this._getFocusableElements();
+ if (focusables.length <= 1) {
+ return;
+ }
+
+ const currentIndex = focusables.indexOf(getActiveElement() as HTMLElement);
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % focusables.length;
+ focusables[nextIndex].focus();
+ return;
+ }
+
+ // In navigation mode (non-edit), always forward Tab to the next item,
+ // bypassing shouldForwardTabAfter() which would stop at internal
+ // focusable elements. F2 enables edit mode for Tab cycling.
+ if (!this.fireDecoratorEvent("forward-after")) {
+ e.preventDefault();
+ }
+ }
+
+ _handleTabPrevious(e: KeyboardEvent) {
+ if (this._editMode) {
+ e.preventDefault();
+ const focusables = this._getFocusableElements();
+ if (focusables.length <= 1) {
+ return;
+ }
+
+ const currentIndex = focusables.indexOf(getActiveElement() as HTMLElement);
+ const prevIndex = currentIndex <= 0 ? focusables.length - 1 : currentIndex - 1;
+ focusables[prevIndex].focus();
+ return;
+ }
+
+ // In navigation mode (non-edit), always forward Shift+Tab to the
+ // previous item. F2 enables edit mode for backward Tab cycling.
+ this.fireDecoratorEvent("forward-before");
+ }
+
_getFocusableElements(): HTMLElement[] {
const focusDomRef = this.getFocusDomRef()!;
return getTabbableElements(focusDomRef);
diff --git a/packages/main/src/ListItemTemplate.tsx b/packages/main/src/ListItemTemplate.tsx
index 6d747378a9dc..2a289da505fe 100644
--- a/packages/main/src/ListItemTemplate.tsx
+++ b/packages/main/src/ListItemTemplate.tsx
@@ -154,8 +154,6 @@ function selectionElement(this: ListItem) {
) : (