Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 115 additions & 15 deletions packages/main/cypress/specs/ListItemCustom.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Test Content Additional Text");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-html")
.shadow()
Expand Down Expand Up @@ -64,7 +64,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Primary Content Secondary Information Paragraph text");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-html-content")
.shadow()
Expand Down Expand Up @@ -98,7 +98,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Button Click me Checkbox Check option Not checked Required . Includes elements");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-ui5")
.shadow()
Expand Down Expand Up @@ -131,13 +131,13 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {

// Click the list item first to get focus
cy.get("#li-custom-ui5-focus").click();

// Verify invisible text is populated
// 2 tabbable controls, so we expect ". Includes elements"
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Button Click Me Checkbox Check Option Not checked . Includes elements");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-ui5-focus")
.shadow()
Expand All @@ -152,14 +152,14 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
// Now click the button - this shouldn't trigger focusout on the list item
// as it's a child element
cy.get("#test-focus-button").click();

// Verify invisible text is still populated (list item should maintain focus state)
cy.get("#ui5-invisible-text")
.should("have.text", "List Item Button Click Me Checkbox Check Option Not checked . Includes elements");

// Click outside the list to truly remove focus
cy.get("body").click({ force: true });

// Now invisible text should be cleared
cy.get("#ui5-invisible-text")
.should("have.text", "");
Expand Down Expand Up @@ -191,7 +191,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Container Text Button Nested Button Paragraph outside container . Includes element");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-nested")
.shadow()
Expand Down Expand Up @@ -230,7 +230,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Button Deeply Nested Button Level 2 Text Checkbox Nested Not checked . Includes elements");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-deep-nested")
.shadow()
Expand All @@ -250,7 +250,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
.should("have.text", "");
});
});

describe("With delete mode and custom delete button", () => {
it("should handle ListItemCustom with delete mode and custom delete button", () => {
// Mount ListItemCustom with delete mode and custom delete button
Expand All @@ -273,7 +273,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item Delete Mode Item Button Remove . Includes element");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-delete")
.shadow()
Expand Down Expand Up @@ -309,7 +309,7 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
cy.get("#ui5-invisible-text")
.should("exist")
.should("have.text", "List Item");

// Check that ariaLabelledByElements on the internal li element includes the global invisible text
cy.get("#li-custom-empty")
.shadow()
Expand All @@ -321,12 +321,12 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
expect(hasInvisibleText).to.be.true;
});
});

it("should handle list item with accessibleName", () => {
cy.mount(
<List>
<ListItemCustom
id="li-custom-accessible-name"
<ListItemCustom
id="li-custom-accessible-name"
accessibleName="Accessible Name Test"
>
<div>This content should not be announced</div>
Expand All @@ -351,4 +351,104 @@ describe("ListItemCustom - _onfocusin and _onfocusout Tests", () => {
});
});
});
});

describe("ListItemCustom - aria-labelledby regression tests", () => {
it("should have non-empty aria-labelledby on non-focused items", () => {
cy.mount(
<List>
<ListItemCustom>
<div>First Item</div>
</ListItemCustom>
<ListItemCustom>
<div>Second Item</div>
</ListItemCustom>
</List>
);

cy.get("[ui5-list]").as("list");

// Neither item is focused — both must have a populated shadow span
cy.get("@list")
.find("[ui5-li-custom]")
.first()
.shadow()
.find("li[part='native-li']")
.then($li => {
const spanId = $li.attr("aria-labelledby")!;
const span = ($li[0].getRootNode() as ShadowRoot).getElementById(spanId);
expect(span?.textContent?.trim()).to.not.be.empty;
});

cy.get("@list")
.find("[ui5-li-custom]")
.last()
.shadow()
.find("li[part='native-li']")
.then($li => {
const spanId = $li.attr("aria-labelledby")!;
const span = ($li[0].getRootNode() as ShadowRoot).getElementById(spanId);
expect(span?.textContent?.trim()).to.not.be.empty;
});
});

it("should preserve accessible name when focus moves to a child interactive element", () => {
cy.mount(
<List>
<ListItemCustom>
<div>Item with button</div>
<Button>Action</Button>
</ListItemCustom>
</List>
);

cy.get("[ui5-list]").as("list");

// Focus the list item — this populates the global invisible text
cy.get("@list")
.find("[ui5-li-custom]")
.click();

cy.get("#ui5-invisible-text")
.should("have.text", "List Item Item with button Button Action . Includes element");

// Move focus to the inner button inside the child ui5-button (C2 scenario)
cy.get("@list")
.find("[ui5-button]")
.shadow()
.find("button")
.focus();

// The global invisible text must NOT be cleared — C2 fix ensures
// _clearInvisibleTextContent is not called when focus stays within the item
cy.get("#ui5-invisible-text")
.should("have.text", "List Item Item with button Button Action . Includes element");
});

it("should preserve accessible name after full focus/blur cycle", () => {
cy.mount(
<List>
<ListItemCustom>
<div>Item Content</div>
</ListItemCustom>
</List>
);

cy.get("[ui5-list]").as("list");

// Focus then blur to trigger the full cycle
cy.get("@list")
.find("[ui5-li-custom]")
.click();

cy.get("body").click({ force: true });

// The shadow span must still contain accessible text after blur — C1 fix
cy.get("@list")
.find("[ui5-li-custom]")
.shadow()
.find("[class='ui5-hidden-text']")
.first()
.should("not.have.text", "");
});
});
33 changes: 30 additions & 3 deletions packages/main/src/ListItemCustom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ListItemCustomTemplate from "./ListItemCustomTemplate.js";
import { getCustomAnnouncement, applyCustomAnnouncement } from "./CustomAnnouncement.js";
import {
LISTITEMCUSTOM_TYPE_TEXT,
LIST_ITEM_ACTIVE,
} from "./generated/i18n/i18n-defaults.js";

// Styles
Expand Down Expand Up @@ -92,6 +93,25 @@ class ListItemCustom extends ListItem {
return `${this._id}-invisibleText`;
}

get ariaLabelledByText(): string {
if (this.accessibleName) {
return this.accessibleName;
}

// Populate shadow span at all times, not only on focus.
const childTexts: string[] = [];
this.childNodes.forEach(child => {
const text = getCustomAnnouncement(child, {}, false);
if (text) {
childTexts.push(text);
}
});

const type = ListItemCustom.i18nBundle.getText(LISTITEMCUSTOM_TYPE_TEXT);
const activeText = this.typeActive ? ListItemCustom.i18nBundle.getText(LIST_ITEM_ACTIVE) : undefined;
return [type, ...childTexts, activeText].filter(Boolean).join(" ");
}

_onfocusin(e: FocusEvent) {
super._onfocusin(e);
// Skip updating invisible text during drag operations
Expand All @@ -102,8 +122,10 @@ class ListItemCustom extends ListItem {

_onfocusout(e: FocusEvent) {
super._onfocusout(e);
// Skip clearing invisible text during drag operations
if (!this._isDragging() && !this.accessibleName) {
// Skip clearing invisible text during drag operations or when focus
// moves to a child element within the same list item (e.g. "Show More" link).
const focusMovedToChild = this.contains(e.relatedTarget as Node);
if (!this._isDragging() && !this.accessibleName && !focusMovedToChild) {
this._clearInvisibleTextContent();
}
}
Expand Down Expand Up @@ -137,8 +159,13 @@ class ListItemCustom extends ListItem {
return;
}

// Clear the announcement by passing empty text
// Save the static aria-labelledby before clearing, because applyCustomAnnouncement
// sets ariaLabelledByElements = null which removes the attribute from the DOM entirely.
const ariaLabelledBy = listItem.getAttribute("aria-labelledby");
applyCustomAnnouncement(listItem, "");
if (ariaLabelledBy && !listItem.getAttribute("aria-labelledby")) {
listItem.setAttribute("aria-labelledby", ariaLabelledBy);
}
}

/**
Expand Down
Loading