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) { ) : (