Skip to content

Commit 0aa9b80

Browse files
authored
feat(ui5-side-navigation): add indication tag slot (#13433)
* feat(ui5-side-navigation): add indication tag slot JIRA: BGSOFUIRODOPI-3651 * fix: popover items cloning * fix: lint errors * chore: add public samples and documentation * fix: address code review comments * chore: address code review comments * chore: add tests and update documentation * chore: remove accidentally committed .claude files * chore: address review comments * chore: delete commented out code * chore: update since tag version
1 parent f0c277e commit 0aa9b80

22 files changed

Lines changed: 754 additions & 29 deletions

packages/fiori/cypress/specs/SideNavigation.cy.tsx

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { NAVIGATION_MENU_SELECTABLE_ITEM_HIDDEN_TEXT } from "../../src/generated
1111
import Title from "@ui5/webcomponents/dist/Title.js";
1212
import Label from "@ui5/webcomponents/dist/Label.js";
1313
import ResponsivePopover from "@ui5/webcomponents/dist/ResponsivePopover.js";
14+
import Tag from "@ui5/webcomponents/dist/Tag.js";
1415

1516
describe("Side Navigation Rendering", () => {
1617
it("Tests rendering in collapsed mode", () => {
@@ -1225,9 +1226,18 @@ describe("Side Navigation Accessibility", () => {
12251226
.should("not.have.attr", "aria-describedby");
12261227

12271228
cy.get("#item2")
1228-
.shadow()
1229-
.find(".ui5-sn-item")
1230-
.should("have.attr", "aria-describedby", "To navigate to navigation item 2, press Spacebar or Enter.");
1229+
.should("have.prop", "__id")
1230+
.then((itemId) => {
1231+
cy.get("#item2")
1232+
.shadow()
1233+
.find(".ui5-sn-item")
1234+
.should("have.attr", "aria-describedby", `${itemId}-selectable-description`);
1235+
1236+
cy.get("#item2")
1237+
.shadow()
1238+
.find(`#${itemId}-selectable-description`)
1239+
.should("have.text", "To navigate to navigation item 2, press Spacebar or Enter.");
1240+
});
12311241

12321242
cy.get("#item1")
12331243
.shadow()
@@ -1723,4 +1733,117 @@ describe("Focusable items", () => {
17231733
.find("ul.ui5-sn-item-ul[role='group']")
17241734
.should("have.attr", "aria-label", "Products");
17251735
});
1736+
1737+
it("Tests SideNavigationItem with tag renders correctly", () => {
1738+
cy.mount(
1739+
<SideNavigation>
1740+
<SideNavigationItem id="item1" text="Without Tag" />
1741+
<SideNavigationItem id="item2" text="With Tag">
1742+
<Tag slot="tag" design="Set2" colorScheme="6" hideStateIcon>New</Tag>
1743+
</SideNavigationItem>
1744+
</SideNavigation>
1745+
);
1746+
1747+
cy.get("#item1")
1748+
.shadow()
1749+
.find(".ui5-sn-item-tag-slot")
1750+
.should("not.exist");
1751+
1752+
cy.get("#item2")
1753+
.find("[ui5-tag]")
1754+
.should("exist")
1755+
.should("have.text", "New");
1756+
1757+
cy.get("#item2")
1758+
.shadow()
1759+
.find(".ui5-sn-item-tag-slot")
1760+
.should("exist");
1761+
});
1762+
1763+
it("Tests SideNavigationItem tag accessibility", () => {
1764+
cy.mount(
1765+
<SideNavigation>
1766+
<SideNavigationItem id="item1" text="Item">
1767+
<Tag slot="tag" design="Set2" colorScheme="6" hideStateIcon>New</Tag>
1768+
</SideNavigationItem>
1769+
</SideNavigation>
1770+
);
1771+
1772+
cy.get("#item1")
1773+
.should("have.prop", "__id")
1774+
.then((itemId) => {
1775+
cy.get("#item1")
1776+
.shadow()
1777+
.find(".ui5-sn-item")
1778+
.should("have.attr", "aria-describedby", `${itemId}-tag`);
1779+
});
1780+
});
1781+
1782+
it("Tests SideNavigationItem tag with subitems has both tag and description", () => {
1783+
cy.mount(
1784+
<SideNavigation>
1785+
<SideNavigationItem id="item1" text="Parent" expanded>
1786+
<Tag slot="tag" design="Set2" colorScheme="8" hideStateIcon>Beta</Tag>
1787+
<SideNavigationSubItem text="Child" />
1788+
</SideNavigationItem>
1789+
</SideNavigation>
1790+
);
1791+
1792+
cy.get("#item1")
1793+
.should("have.prop", "__id")
1794+
.then((itemId) => {
1795+
cy.get("#item1")
1796+
.shadow()
1797+
.find(".ui5-sn-item")
1798+
.invoke("attr", "aria-describedby")
1799+
.should("contain", `${itemId}-tag`)
1800+
.should("contain", `${itemId}-selectable-description`);
1801+
});
1802+
});
1803+
1804+
it("Tests SideNavigationSubItem with tag", () => {
1805+
cy.mount(
1806+
<SideNavigation>
1807+
<SideNavigationItem text="Parent" expanded>
1808+
<SideNavigationSubItem id="subItem1" text="SubItem">
1809+
<Tag slot="tag" design="Set2" colorScheme="5" hideStateIcon>Dev</Tag>
1810+
</SideNavigationSubItem>
1811+
</SideNavigationItem>
1812+
</SideNavigation>
1813+
);
1814+
1815+
cy.get("#subItem1")
1816+
.find("[ui5-tag]")
1817+
.should("exist")
1818+
.should("have.text", "Dev");
1819+
1820+
cy.get("#subItem1")
1821+
.should("have.prop", "__id")
1822+
.then((itemId) => {
1823+
cy.get("#subItem1")
1824+
.shadow()
1825+
.find(".ui5-sn-item")
1826+
.should("have.attr", "aria-describedby", `${itemId}-tag`);
1827+
});
1828+
});
1829+
1830+
it("Tests tag in collapsed mode popover", () => {
1831+
cy.mount(
1832+
<SideNavigation id="sideNav" collapsed>
1833+
<SideNavigationItem id="item1" text="Item" icon="home">
1834+
<Tag slot="tag" design="Set2" colorScheme="6" hideStateIcon>New</Tag>
1835+
<SideNavigationSubItem text="SubItem" />
1836+
</SideNavigationItem>
1837+
</SideNavigation>
1838+
);
1839+
1840+
cy.get("#item1").realClick();
1841+
1842+
cy.get("#sideNav")
1843+
.shadow()
1844+
.find("[ui5-responsive-popover] [ui5-side-navigation-item]")
1845+
.find("[ui5-tag]")
1846+
.should("exist")
1847+
.should("have.text", "New");
1848+
});
17261849
});

packages/fiori/src/NavigationMenuItem.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
isEnterAlt,
1515
} from "@ui5/webcomponents-base/dist/Keys.js";
1616
import type SideNavigationSelectableItemBase from "./SideNavigationSelectableItemBase.js";
17+
import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
18+
import type { Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
1719

1820
// Templates
1921
import NavigationMenuItemTemplate from "./NavigationMenuItemTemplate.js";
@@ -93,6 +95,13 @@ class NavigationMenuItem extends MenuItem {
9395

9496
associatedItem?: SideNavigationSelectableItemBase;
9597

98+
@slot({ type: HTMLElement })
99+
tag!: Slot<HTMLElement>;
100+
101+
get hasTag() {
102+
return !!this.tag.length;
103+
}
104+
96105
get isExternalLink() {
97106
return this.href && this.target === "_blank";
98107
}
@@ -101,13 +110,28 @@ class NavigationMenuItem extends MenuItem {
101110
return (!this.disabled && this.href) ? this.href : undefined;
102111
}
103112

113+
get _tagContainerId() {
114+
return `${this._id}-tag-container`;
115+
}
116+
117+
get _ariaDescribedByIds() {
118+
const ids = [
119+
`${this._id}-invisibleText-describedby`,
120+
];
121+
122+
if (this.hasTag) {
123+
ids.push(this._tagContainerId);
124+
}
125+
126+
return ids.filter(Boolean).join(" ");
127+
}
128+
104129
get _accInfo() {
105130
const accInfo = super._accInfo;
106131

107132
accInfo.role = "none";
108133

109134
if (this.hasSubmenu && this.associatedItem?.isSelectable) {
110-
// For the menu item on first level (parent item)
111135
accInfo.ariaSelectedText = NavigationMenuItem.i18nBundleFiori.getText(NAVIGATION_MENU_SELECTABLE_ITEM_HIDDEN_TEXT);
112136
}
113137

packages/fiori/src/NavigationMenuItemTemplate.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,24 @@ function iconBegin(this: NavigationMenuItem) {
4747
}
4848

4949
function iconEnd(this: NavigationMenuItem) {
50-
if (this.hasSubmenu) {
51-
return <Icon
52-
part="icon"
53-
name={slimArrowRightIcon}
54-
class="ui5-menu-item-icon-end"
55-
/>;
56-
}
57-
58-
if (this.isExternalLink) {
59-
return <Icon
60-
class="ui5-sn-item-external-link-icon"
61-
name={arrowRightIcon}
62-
/>;
63-
}
50+
return (<>
51+
{this.hasTag &&
52+
<span id={this._tagContainerId} class="ui5-navmenu-item-tag-container">
53+
<slot name="tag"></slot>
54+
</span>
55+
}
56+
{this.hasSubmenu &&
57+
<Icon
58+
part="icon"
59+
name={slimArrowRightIcon}
60+
class="ui5-menu-item-icon-end"
61+
/>
62+
}
63+
{!this.hasSubmenu && this.isExternalLink &&
64+
<Icon
65+
class="ui5-sn-item-external-link-icon"
66+
name={arrowRightIcon}
67+
/>
68+
}
69+
</>);
6470
}

packages/fiori/src/SideNavigation.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,8 +746,22 @@ class SideNavigation extends UI5Element {
746746
}
747747

748748
captureRef(ref: HTMLElement & { associatedItem?: UI5Element } | null) {
749-
if (ref) {
750-
ref.associatedItem = this;
749+
if (!ref) {
750+
return;
751+
}
752+
753+
ref.associatedItem = this;
754+
755+
const item = this as unknown as SideNavigationItem | SideNavigationSubItem;
756+
if (item.tag?.length > 0) {
757+
const existingTags = Array.from(ref.children).filter(child => child.getAttribute("slot") === "tag");
758+
existingTags.forEach(tag => tag.remove());
759+
760+
item.tag.forEach((tagEl: HTMLElement) => {
761+
const clonedTag = tagEl.cloneNode(true) as HTMLElement;
762+
clonedTag.setAttribute("slot", "tag");
763+
ref.appendChild(clonedTag);
764+
});
751765
}
752766
}
753767
}

packages/fiori/src/SideNavigationItem.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
SIDE_NAVIGATION_OVERFLOW_ITEM_LABEL,
2121
SIDE_NAVIGATION_PARENT_ITEM_SELECTABLE_DESCRIPTION,
2222
} from "./generated/i18n/i18n-defaults.js";
23-
import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js";
23+
import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
24+
import "@ui5/webcomponents/dist/Tag.js";
2425

2526
// Templates
2627
import SideNavigationItemTemplate from "./SideNavigationItemTemplate.js";
@@ -81,6 +82,27 @@ class SideNavigationItem extends SideNavigationSelectableItemBase {
8182
@slot({ type: HTMLElement, invalidateOnChildChange: true, "default": true })
8283
items!: DefaultSlot<SideNavigationSubItem>;
8384

85+
/**
86+
* Defines the tag to be displayed.
87+
*
88+
* **Note:** Tags are visible when the <code>NavigationList</code> is in expanded mode,
89+
* and hidden when collapsed, but they are visible in the overflow of the collapsed mode.
90+
*
91+
* **Note:** Only one `ui5-tag` is allowed. The tag should use `design="Set2"`, `hide-state-icon`,
92+
* and `colorScheme` values 5-10 to avoid confusion with semantic colors (1-4).
93+
*
94+
* **Note:** It is recommended to limit tag width to 64px (4rem). If tag text exceeds this,
95+
* use shortened forms or abbreviations (e.g., "Experimental" → "Exp").
96+
*
97+
* **Important:** The <code>ui5-tag</code> must never be interactive (i.e., <code>active</code> must not be set to <code>true</code>),
98+
* as this would lead to nesting of interactive elements, which is not allowed.
99+
*
100+
* @public
101+
* @since 2.23.0
102+
*/
103+
@slot({ type: HTMLElement })
104+
tag!: Slot<HTMLElement>;
105+
84106
@i18n("@ui5/webcomponents-fiori")
85107
static i18nBundle: I18nBundle;
86108

@@ -183,9 +205,36 @@ class SideNavigationItem extends SideNavigationSelectableItemBase {
183205
}
184206

185207
get _describedBy() {
208+
const parts: string[] = [];
209+
210+
if (this.hasTag) {
211+
parts.push(this._tagId);
212+
}
213+
214+
if (!this.effectiveDisabled && this.items.length && !this.unselectable) {
215+
parts.push(this._selectableItemDescriptionId);
216+
}
217+
218+
return parts.length > 0 ? parts.join(" ") : undefined;
219+
}
220+
221+
get _selectableItemDescriptionId() {
222+
return `${this._id}-selectable-description`;
223+
}
224+
225+
get _selectableItemDescriptionText() {
186226
if (!this.effectiveDisabled && this.items.length && !this.unselectable) {
187227
return SideNavigationItem.i18nBundle.getText(SIDE_NAVIGATION_PARENT_ITEM_SELECTABLE_DESCRIPTION, this.text ?? "");
188228
}
229+
return undefined;
230+
}
231+
232+
get hasTag() {
233+
return !!this.tag.length;
234+
}
235+
236+
get _textId() {
237+
return `${this._id}-text`;
189238
}
190239

191240
get classesArray() {

packages/fiori/src/SideNavigationItemTemplate.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,20 @@ function ItemTemplate(this: SideNavigationItem) {
4444
aria-owns={this._groupId}
4545
aria-label={this._ariaLabel}
4646
aria-expanded={this._expanded}
47+
aria-labelledby={this._textId}
4748
aria-describedby={this._describedBy}
4849
>
4950
{this.sideNavCollapsed ?
5051
<Icon class="ui5-sn-item-icon" name={this.icon}/>
5152
:
5253
this.icon && <Icon class="ui5-sn-item-icon" name={this.icon}/>
5354
}
54-
<div class="ui5-sn-item-text">{this.text}</div>
55+
<div class="ui5-sn-item-text" id={this._textId}>{this.text}</div>
56+
{this.hasTag &&
57+
<div id={this._tagId} class="ui5-sn-item-tag-slot">
58+
<slot name="tag"></slot>
59+
</div>
60+
}
5561
{this.sideNavCollapsed ?
5662
!!this.items.length &&
5763
<Icon class="ui5-sn-item-toggle-icon"
@@ -81,6 +87,9 @@ function ItemTemplate(this: SideNavigationItem) {
8187
<slot></slot>
8288
</ul>
8389
}
90+
{this._selectableItemDescriptionText &&
91+
<span id={this._selectableItemDescriptionId} class="ui5-hidden-text">{this._selectableItemDescriptionText}</span>
92+
}
8493
</>
8594
);
8695
}

0 commit comments

Comments
 (0)