From e31eb5f2a9016cff53357f59e5d05b48bcf29883 Mon Sep 17 00:00:00 2001 From: Konstantin Gogov Date: Fri, 13 Mar 2026 14:17:28 +0200 Subject: [PATCH 1/2] fix(ui5-li): make delete button keyboard accessible in Delete selection mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete button was intentionally removed from the tab chain in 2021 (commit 5176954, PR #3290) based on accessibility guidance at the time. This decision has since been reconsidered — UX now requires the button to be keyboard accessible, as the Delete key shortcut alone is not intuitive for most users. Removed tabindex="-1" and data-sap-no-tab-ref from the delete button, making it consistent with the detail button. Tab, Shift+Tab, and F2 now reach the delete button within list items. Fixes #13220 Relates to #2964 --- packages/main/cypress/specs/List.cy.tsx | 194 ++++++++++++++++-------- packages/main/src/ListItemTemplate.tsx | 2 - 2 files changed, 132 insertions(+), 64 deletions(-) diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index eb6dfe355402..3fde23adcca5 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -153,10 +153,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 +167,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 +182,7 @@ describe("List Tests", () => { Product 3 ); - + cy.get("[ui5-list]") .as("list"); @@ -652,22 +652,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 +686,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 +710,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 +730,7 @@ describe("List Tests", () => { China ); - + cy.get("[ui5-list]").then(($list) => { const stub = cy.stub().as("selectionChangeStub"); stub.callsFake((event) => { @@ -738,11 +738,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 +756,7 @@ describe("List Tests", () => { China ); - + cy.get("[ui5-list]").then(($list) => { const stub = cy.stub().as("selectionChangeStub"); stub.callsFake((event) => { @@ -764,13 +764,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 +1129,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 +1180,97 @@ 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. Tab key moves focus to 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 should move focus to the delete button inside the first item + cy.realPress("Tab"); + 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"); + + // Tab again from delete button should move focus out of the item + cy.get("[ui5-li]").first().click(); + cy.realPress("Tab"); + cy.get("[ui5-li]") + .first() + .shadow() + .find("[ui5-button]") + .should("be.focused"); + + cy.realPress("Tab"); + cy.get("button").last().should("be.focused"); + }); + + 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; @@ -1714,16 +1784,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 +1940,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 +1953,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 +1964,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 +1990,7 @@ describe("List Tests", () => { ); - + cy.get("[ui5-list]").then(($list) => { const stub = cy.stub().as("itemClickStub"); stub.callsFake((event) => { @@ -1928,9 +1998,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 +2183,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 +2557,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 +2624,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 +2695,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 +2731,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 +2742,4 @@ describe("List sticky header", () => { }); }); }); -}); \ No newline at end of file +}); 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) { ) : ( @@ -1221,8 +1222,13 @@ describe("List Tests", () => { cy.get("[ui5-li]").first().click(); cy.get("[ui5-li]").first().should("be.focused"); - // Tab should move focus to the delete button inside the first item + // 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() @@ -1232,18 +1238,6 @@ describe("List Tests", () => { // Enter on the focused delete button should trigger deletion cy.realPress("Enter"); cy.get("@itemDeleteStub").should("have.been.calledOnce"); - - // Tab again from delete button should move focus out of the item - cy.get("[ui5-li]").first().click(); - cy.realPress("Tab"); - cy.get("[ui5-li]") - .first() - .shadow() - .find("[ui5-button]") - .should("be.focused"); - - cy.realPress("Tab"); - cy.get("button").last().should("be.focused"); }); it("selectionMode: delete. F2 toggles focus to delete button", () => { @@ -1385,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() @@ -1452,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"); @@ -1489,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"); @@ -1526,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"); @@ -1745,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"); }); @@ -2743,3 +2726,326 @@ describe("List sticky header", () => { }); }); }); + +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/test/pages/ListEditMode.html b/packages/main/test/pages/ListEditMode.html new file mode 100644 index 000000000000..6857fd23cb1e --- /dev/null +++ b/packages/main/test/pages/ListEditMode.html @@ -0,0 +1,333 @@ + + + + + + + List Edit Mode - Keyboard Navigation Test + + + + + +

List Edit Mode - Keyboard Navigation

+ +
+ Navigation Mode (default): Arrow Up/Down between items, Tab forwards out of list. +

+ Edit Mode: Press F2 or F7 to enter. Tab cycles through internal elements. Press F2 / F7 again to exit. +

+ Arrow navigation in edit mode: Arrow Up/Down moves to the same-index element in adjacent items. +
+ + +

1. Delete Mode - ListItemStandard

+

Expected: F2 → focus on delete button → Tab stays (single focusable) → F2 exits

+ +
+ Before + + Laptop HP + Monitor Dell 27" + Keyboard Mechanical + + After +
+ + +

2. Delete Mode - ListItemCustom (multiple interactive elements)

+

Expected: F2 → focus on first button → Tab cycles through buttons, link, and delete button → wraps around

+ +
+ Before + + +
+ Edit + Details + Archive +
+
+ +
+ View + + Submit +
+
+
+ After +
+ + +

3. Delete Mode - Custom Delete Button Slot

+

Expected: F2 → focus on custom delete button → Tab stays (single focusable)

+ +
+ + Standard delete button + + Custom delete button +
+ Remove +
+
+
+
+ + +

4. Delete Mode - Mixed Item Types

+

Expected: Arrow Down/Up in edit mode transfers edit mode. Focus index clamped to available elements.

+ +
+ + +
+ Action A + Action B + Action C +
+
+ Standard item (only delete button) + +
+ + Go +
+
+
+
+ + +

5. SingleEnd Mode - ListItemCustom

+

Expected: Tab forwards (does NOT enter interactive elements). F2/F7 required to access buttons.

+ +
+ Before + + +
+ Press me + Go to SAP +
+
+ +
+ Another button + Another link +
+
+
+ After +
+ + +

6. Multiple Mode - ListItemCustom

+

Expected: Same edit mode behavior. Tab forwards, F2 to enter, checkbox is part of cycle.

+ +
+ + +
+ Edit + Delete +
+
+ +
+ View + Share +
+
+
+
+ + +

7. None Mode - ListItemCustom

+

Expected: Tab forwards out. F2/F7 to enter internal elements.

+ +
+ Before + + +
+ Action 1 + Action 2 + +
+
+ +
+ Action 3 + Link +
+
+
+ After +
+ + +

8. Detail Type with Delete Mode

+

Expected: F2 cycles through detail button + delete button.

+ +
+ + Detail item with delete + Another detail item + +
+ + +

9. Focus-out Clears Edit Mode

+

Expected: Enter edit mode → click outside → edit mode cleared → Tab forwards again.

+ +
+ + +
+ Inside Button + Inside Link +
+
+
+ Click me to move focus outside +
+ + +

10. Growing Button + Delete Mode

+

Expected: Tab from item goes to growing button (not internal elements).

+ +
+ + Item 1 + Item 2 + Item 3 + +
+ + +
+ Focus: + - + | + Edit mode: + - + | + Last key: + - +
+ + + + +