From abc1d3723bf24367b01e08af93d1f702c8c869a0 Mon Sep 17 00:00:00 2001 From: Lidiya Georgieva Date: Fri, 24 Jul 2026 10:54:13 +0300 Subject: [PATCH 1/4] feat(ui5-side-navigation): fire item-toggle event on expand/collapse Add a cancelable "item-toggle" event that fires whenever a ui5-side-navigation-item or ui5-side-navigation-group is expanded or collapsed - via click, keyboard (arrows, +/-), or programmatic change to the "expanded" property. The event detail exposes: - item: the toggled item - expanded: the new (requested) expanded state - programmatic: whether the change came from a property set (true) or user interaction (false) Calling preventDefault() suppresses the toggle, leaving the expanded state unchanged (useful for lazy-loading child items before expand). Fixes #13826 --- .../fiori/cypress/specs/SideNavigation.cy.tsx | 144 ++++++++++++++++++ .../specs/SideNavigationWithGroups.cy.tsx | 141 +++++++++++++++++ packages/fiori/src/SideNavigation.ts | 28 +++- packages/fiori/src/SideNavigationGroup.ts | 30 +++- packages/fiori/src/SideNavigationItem.ts | 29 +++- packages/fiori/src/SideNavigationItemBase.ts | 47 ++++++ .../test/pages/SideNavigationWithGroups.html | 41 ++++- 7 files changed, 445 insertions(+), 15 deletions(-) diff --git a/packages/fiori/cypress/specs/SideNavigation.cy.tsx b/packages/fiori/cypress/specs/SideNavigation.cy.tsx index 9b85c2a090660..28c54e1216ba0 100644 --- a/packages/fiori/cypress/specs/SideNavigation.cy.tsx +++ b/packages/fiori/cypress/specs/SideNavigation.cy.tsx @@ -1052,6 +1052,150 @@ describe("Side Navigation interaction", () => { }); }); +describe("item-toggle event", () => { + it("fires on user toggle-icon click with programmatic: false", () => { + cy.mount( + + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#item1").shadow().find(".ui5-sn-item-toggle-icon").realClick(); + + cy.get("@toggle").should("have.been.calledOnce"); + cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { + expanded: true, + programmatic: false, + }); + cy.get("@toggle").its("firstCall.args.0.detail.item").should(item => { + expect(item.id).to.equal("item1"); + }); + cy.get("#item1").should("have.attr", "expanded"); + }); + + it("fires on keyboard Plus/Minus with programmatic: false", () => { + cy.mount( + + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#focusStart").realClick(); + cy.realPress("ArrowDown"); + cy.realPress("+"); + + cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { + expanded: true, + programmatic: false, + }); + cy.get("#item1").should("have.attr", "expanded"); + + cy.realPress("-"); + + cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { + expanded: false, + programmatic: false, + }); + cy.get("#item1").should("not.have.attr", "expanded"); + }); + + it("fires on programmatic change with programmatic: true", () => { + cy.mount( + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#item1").invoke("prop", "expanded", true); + + cy.get("@toggle").should("have.been.calledOnce"); + cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { + expanded: true, + programmatic: true, + }); + cy.get("#item1").should("have.prop", "expanded", true); + }); + + it("preventDefault suppresses the toggle", () => { + cy.mount( + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", (e: Event) => e.preventDefault()); + }); + + cy.get("#item1").shadow().find(".ui5-sn-item-toggle-icon").realClick(); + + cy.get("#item1").should("have.prop", "expanded", false); + cy.get("#item1").should("not.have.attr", "expanded"); + }); + + it("does not fire during initial rendering", () => { + cy.mount( + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#item1").should("have.prop", "expanded", true); + cy.get("@toggle").should("not.have.been.called"); + }); + + it("does not fire on the outer side navigation when a collapsed parent opens the picker", () => { + cy.mount( + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#item1").realClick(); + + cy.get("#sideNav") + .shadow() + .find("[ui5-responsive-popover]") + .should("be.visible"); + + cy.get("@toggle").should("not.have.been.called"); + }); +}); + describe("Side Navigation Accessibility", () => { it("SideNavigationItem ariaHasPopup", () => { cy.mount( diff --git a/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx b/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx index 44782a1791feb..0ccfc68a3feea 100644 --- a/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx +++ b/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx @@ -317,4 +317,145 @@ describe("Component Behavior", () => { .should("have.attr", "tabindex", "0"); }); }); + + describe("item-toggle event", () => { + it("fires on user click with programmatic: false", () => { + cy.mount( + + + + + ); + + cy.get("#sn1").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#group1") + .shadow() + .find(".ui5-sn-item") + .realClick(); + + cy.get("@toggle").should("have.been.calledOnce"); + cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { + expanded: false, + programmatic: false, + }); + cy.get("@toggle").its("firstCall.args.0.detail.item").should(item => { + expect(item.id).to.equal("group1"); + }); + cy.get("#group1").should("have.prop", "expanded", false); + }); + + it("fires on keyboard Plus/Minus with programmatic: false", () => { + cy.mount( + + + + + + ); + + cy.get("#sn").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#focusStart").realClick(); + cy.realPress("ArrowDown"); + cy.realPress("+"); + + cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { + expanded: true, + programmatic: false, + }); + cy.get("#group1").should("have.attr", "expanded"); + + cy.realPress("-"); + + cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { + expanded: false, + programmatic: false, + }); + cy.get("#group1").should("not.have.attr", "expanded"); + }); + + it("fires on programmatic change with programmatic: true", () => { + cy.mount( + + + + + ); + + cy.get("#sn1").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#group1").invoke("prop", "expanded", true); + + cy.get("@toggle").should("have.been.calledOnce"); + cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { + expanded: true, + programmatic: true, + }); + cy.get("#group1").should("have.prop", "expanded", true); + }); + + it("preventDefault suppresses the toggle", () => { + cy.mount( + + + + + ); + + cy.get("#sn1").then($el => { + $el[0].addEventListener("item-toggle", (e: Event) => e.preventDefault()); + }); + + cy.get("#group1") + .shadow() + .find(".ui5-sn-item") + .realClick(); + + cy.get("#group1").should("have.prop", "expanded", true); + cy.get("#group1") + .shadow() + .find(".ui5-sn-item-group") + .should("have.attr", "aria-expanded", "true"); + }); + + it("preventDefault suppresses programmatic toggle", () => { + cy.mount( + + + + + ); + + cy.get("#sn1").then($el => { + $el[0].addEventListener("item-toggle", (e: Event) => e.preventDefault()); + }); + + cy.get("#group1").invoke("prop", "expanded", true); + + cy.get("#group1").should("have.prop", "expanded", false); + }); + + it("does not fire during initial rendering", () => { + cy.mount( + + + + + ); + + cy.get("#sn1").then($el => { + $el[0].addEventListener("item-toggle", cy.stub().as("toggle")); + }); + + cy.get("#group1").should("have.prop", "expanded", true); + cy.get("@toggle").should("not.have.been.called"); + }); + }); }); \ No newline at end of file diff --git a/packages/fiori/src/SideNavigation.ts b/packages/fiori/src/SideNavigation.ts index 046031c798a71..930c546900e17 100644 --- a/packages/fiori/src/SideNavigation.ts +++ b/packages/fiori/src/SideNavigation.ts @@ -57,6 +57,12 @@ type SideNavigationItemClickEventDetail = { item: SideNavigationSelectableItemBase, }; +type SideNavigationItemToggleEventDetail = { + item: SideNavigationItemBase, + expanded: boolean, + programmatic: boolean, +}; + type PopupSideNavigationItem = SideNavigationItem & { associatedItem: SideNavigationSelectableItemBase }; /** @@ -138,10 +144,29 @@ type PopupSideNavigationItem = SideNavigationItem & { associatedItem: SideNaviga cancelable: true, }) +/** + * Fired when a `ui5-side-navigation-item` or `ui5-side-navigation-group` is expanded or collapsed. + * + * **Note:** You can call `preventDefault()` on the event to suppress the expand/collapse. + * The `expanded` state stays unchanged. This is handy, for example, if you want to + * dynamically load child items before allowing a parent item to expand. + * + * @param {SideNavigationItemBase} item The toggled item. + * @param {boolean} expanded The new (requested) expanded state of the item. + * @param {boolean} programmatic Indicates whether the change was triggered programmatically (`true`) or by user interaction (`false`). + * @since 2.25.0 + * @public + */ +@event("item-toggle", { + bubbles: true, + cancelable: true, +}) + class SideNavigation extends UI5Element { eventDetails!: { "selection-change": SideNavigationSelectionChangeEventDetail, - "item-click": SideNavigationItemClickEventDetail + "item-click": SideNavigationItemClickEventDetail, + "item-toggle": SideNavigationItemToggleEventDetail } /** @@ -776,4 +801,5 @@ export default SideNavigation; export type { SideNavigationSelectionChangeEventDetail, SideNavigationItemClickEventDetail, + SideNavigationItemToggleEventDetail, }; diff --git a/packages/fiori/src/SideNavigationGroup.ts b/packages/fiori/src/SideNavigationGroup.ts index 5897e26758833..a1e66996dec5b 100644 --- a/packages/fiori/src/SideNavigationGroup.ts +++ b/packages/fiori/src/SideNavigationGroup.ts @@ -11,7 +11,7 @@ import { } from "@ui5/webcomponents-base/dist/Keys.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; -import SideNavigationItemBase from "./SideNavigationItemBase.js"; +import SideNavigationItemBase, { toggleExpanded } from "./SideNavigationItemBase.js"; import type SideNavigationSelectableItemBase from "./SideNavigationSelectableItemBase.js"; import type SideNavigationItem from "./SideNavigationItem.js"; import SideNavigationGroupTemplate from "./SideNavigationGroupTemplate.js"; @@ -58,7 +58,17 @@ class SideNavigationGroup extends SideNavigationItemBase { * @default false */ @property({ type: Boolean }) - expanded = false; + get expanded(): boolean { + return this._expandedState; + } + + set expanded(value: boolean) { + this._expandedState = toggleExpanded(this, value); + } + + _expandedState = false; + + _userToggle = false; belowGroup = false; @@ -154,24 +164,24 @@ class SideNavigationGroup extends SideNavigationItemBase { if (isLeft(e)) { e.preventDefault(); - this.expanded = isRTL; + this._setExpandedByUser(isRTL); return; } if (isRight(e)) { e.preventDefault(); - this.expanded = !isRTL; + this._setExpandedByUser(!isRTL); } if (isMinus(e)) { e.preventDefault(); - this.expanded = false; + this._setExpandedByUser(false); return; } if (isPlus(e)) { e.preventDefault(); - this.expanded = true; + this._setExpandedByUser(true); } } @@ -187,10 +197,16 @@ class SideNavigationGroup extends SideNavigationItemBase { _toggle() { if (!this.disabled) { - this.expanded = !this.expanded; + this._setExpandedByUser(!this.expanded); } } + _setExpandedByUser(value: boolean) { + this._userToggle = true; + this.expanded = value; + this._userToggle = false; + } + get isSideNavigationGroup() { return true; } diff --git a/packages/fiori/src/SideNavigationItem.ts b/packages/fiori/src/SideNavigationItem.ts index e39c3b534065b..feee121544166 100644 --- a/packages/fiori/src/SideNavigationItem.ts +++ b/packages/fiori/src/SideNavigationItem.ts @@ -12,6 +12,7 @@ import { isEnter, } from "@ui5/webcomponents-base/dist/Keys.js"; import type SideNavigationItemBase from "./SideNavigationItemBase.js"; +import { toggleExpanded } from "./SideNavigationItemBase.js"; import SideNavigationSelectableItemBase from "./SideNavigationSelectableItemBase.js"; import type SideNavigationSubItem from "./SideNavigationSubItem.js"; import { @@ -62,7 +63,17 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { * @default false */ @property({ type: Boolean }) - expanded = false; + get expanded(): boolean { + return this._expandedState; + } + + set expanded(value: boolean) { + this._expandedState = toggleExpanded(this, value); + } + + _expandedState = false; + + _userToggle = false; /** * Defines if the item should be collapsible or not. @@ -304,25 +315,25 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { if (isLeft(e)) { e.preventDefault(); - this.expanded = isRTL; + this._setExpandedByUser(isRTL); return; } if (isRight(e)) { e.preventDefault(); - this.expanded = !isRTL; + this._setExpandedByUser(!isRTL); return; } if (isMinus(e)) { e.preventDefault(); - this.expanded = false; + this._setExpandedByUser(false); return; } if (isPlus(e)) { e.preventDefault(); - this.expanded = true; + this._setExpandedByUser(true); return; } @@ -381,10 +392,16 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { _toggle() { if (this.items.length && !this.effectiveDisabled) { - this.expanded = !this.expanded; + this._setExpandedByUser(!this.expanded); } } + _setExpandedByUser(value: boolean) { + this._userToggle = true; + this.expanded = value; + this._userToggle = false; + } + get isSideNavigationItem() { return true; } diff --git a/packages/fiori/src/SideNavigationItemBase.ts b/packages/fiori/src/SideNavigationItemBase.ts index a1781507a77a8..f3a31edce5d98 100644 --- a/packages/fiori/src/SideNavigationItemBase.ts +++ b/packages/fiori/src/SideNavigationItemBase.ts @@ -170,3 +170,50 @@ export type { SideNavigationItemClickEventDetail, }; export const isInstanceOfSideNavigationItemBase = createInstanceChecker("isSideNavigationItemBase"); + +type ExpandableItem = SideNavigationItemBase & { + _expandedState: boolean; + _userToggle: boolean; +}; + +/** + * Shared logic for the `expanded` property setter of the expandable items + * (`SideNavigationGroup` and `SideNavigationItem`). + * + * Fires the cancelable `item-toggle` event on the parent `ui5-side-navigation` and + * returns the effective value to be stored. When the event is prevented, the old value + * is kept (the toggle is suppressed). The `_userToggle` flag on the item distinguishes + * user interaction (`programmatic: false`) from programmatic assignments (`programmatic: true`). + * + * @private + */ +const toggleExpanded = (item: ExpandableItem, value: boolean): boolean => { + if (item._expandedState === value) { + return item._expandedState; + } + + const sideNav = item.sideNavigation; + + // Before the item is wired to a parent side navigation there is nothing to fire the + // event on - just store the value silently (e.g. when set before insertion in the DOM). + if (!sideNav) { + return value; + } + + const executeEvent = sideNav.fireDecoratorEvent("item-toggle", { + item, + expanded: value, + programmatic: !item._userToggle, + }); + + // The event was prevented - keep the old value, suppressing the toggle. + if (!executeEvent) { + return item._expandedState; + } + + return value; +}; + +export { + toggleExpanded, +}; diff --git a/packages/fiori/test/pages/SideNavigationWithGroups.html b/packages/fiori/test/pages/SideNavigationWithGroups.html index a0009e75ad699..2c833d513a34a 100644 --- a/packages/fiori/test/pages/SideNavigationWithGroups.html +++ b/packages/fiori/test/pages/SideNavigationWithGroups.html @@ -19,7 +19,7 @@ icon="home" href="#home" tooltip="Home 1 tooltip"> - + @@ -109,6 +109,14 @@
Change width +
+ Toggle "Group" programmatically + Toggle "People" item programmatically + +
+ item-toggle log +
From 25132590e47bb782fc7386131d6966ca0c3d9ccc Mon Sep 17 00:00:00 2001 From: Lidiya Georgieva Date: Tue, 28 Jul 2026 14:31:26 +0300 Subject: [PATCH 2/4] chore: simplify event parameters --- .../fiori/cypress/specs/SideNavigation.cy.tsx | 26 +++-------- .../specs/SideNavigationWithGroups.cy.tsx | 43 +++---------------- packages/fiori/src/SideNavigation.ts | 8 +--- packages/fiori/src/SideNavigationGroup.ts | 16 +------ packages/fiori/src/SideNavigationItem.ts | 16 +------ packages/fiori/src/SideNavigationItemBase.ts | 36 ++++++---------- .../test/pages/SideNavigationWithGroups.html | 21 ++------- 7 files changed, 35 insertions(+), 131 deletions(-) diff --git a/packages/fiori/cypress/specs/SideNavigation.cy.tsx b/packages/fiori/cypress/specs/SideNavigation.cy.tsx index 28c54e1216ba0..278befbb0459f 100644 --- a/packages/fiori/cypress/specs/SideNavigation.cy.tsx +++ b/packages/fiori/cypress/specs/SideNavigation.cy.tsx @@ -1053,7 +1053,7 @@ describe("Side Navigation interaction", () => { }); describe("item-toggle event", () => { - it("fires on user toggle-icon click with programmatic: false", () => { + it("fires on user toggle-icon click", () => { cy.mount( @@ -1070,17 +1070,13 @@ describe("item-toggle event", () => { cy.get("#item1").shadow().find(".ui5-sn-item-toggle-icon").realClick(); cy.get("@toggle").should("have.been.calledOnce"); - cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { - expanded: true, - programmatic: false, - }); cy.get("@toggle").its("firstCall.args.0.detail.item").should(item => { expect(item.id).to.equal("item1"); }); cy.get("#item1").should("have.attr", "expanded"); }); - it("fires on keyboard Plus/Minus with programmatic: false", () => { + it("fires on keyboard Plus/Minus", () => { cy.mount( @@ -1098,22 +1094,18 @@ describe("item-toggle event", () => { cy.realPress("ArrowDown"); cy.realPress("+"); - cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { - expanded: true, - programmatic: false, + cy.get("@toggle").its("lastCall.args.0.detail.item").should(item => { + expect(item.id).to.equal("item1"); }); cy.get("#item1").should("have.attr", "expanded"); cy.realPress("-"); - cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { - expanded: false, - programmatic: false, - }); + cy.get("@toggle").should("have.been.calledTwice"); cy.get("#item1").should("not.have.attr", "expanded"); }); - it("fires on programmatic change with programmatic: true", () => { + it("does not fire on programmatic change", () => { cy.mount( @@ -1128,12 +1120,8 @@ describe("item-toggle event", () => { cy.get("#item1").invoke("prop", "expanded", true); - cy.get("@toggle").should("have.been.calledOnce"); - cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { - expanded: true, - programmatic: true, - }); cy.get("#item1").should("have.prop", "expanded", true); + cy.get("@toggle").should("not.have.been.called"); }); it("preventDefault suppresses the toggle", () => { diff --git a/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx b/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx index 0ccfc68a3feea..7cbe2520b22e1 100644 --- a/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx +++ b/packages/fiori/cypress/specs/SideNavigationWithGroups.cy.tsx @@ -319,7 +319,7 @@ describe("Component Behavior", () => { }); describe("item-toggle event", () => { - it("fires on user click with programmatic: false", () => { + it("fires on user click", () => { cy.mount( @@ -337,17 +337,13 @@ describe("Component Behavior", () => { .realClick(); cy.get("@toggle").should("have.been.calledOnce"); - cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { - expanded: false, - programmatic: false, - }); cy.get("@toggle").its("firstCall.args.0.detail.item").should(item => { expect(item.id).to.equal("group1"); }); cy.get("#group1").should("have.prop", "expanded", false); }); - it("fires on keyboard Plus/Minus with programmatic: false", () => { + it("fires on keyboard Plus/Minus", () => { cy.mount( @@ -364,22 +360,18 @@ describe("Component Behavior", () => { cy.realPress("ArrowDown"); cy.realPress("+"); - cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { - expanded: true, - programmatic: false, + cy.get("@toggle").its("lastCall.args.0.detail.item").should(item => { + expect(item.id).to.equal("group1"); }); cy.get("#group1").should("have.attr", "expanded"); cy.realPress("-"); - cy.get("@toggle").its("lastCall.args.0.detail").should("deep.include", { - expanded: false, - programmatic: false, - }); + cy.get("@toggle").should("have.been.calledTwice"); cy.get("#group1").should("not.have.attr", "expanded"); }); - it("fires on programmatic change with programmatic: true", () => { + it("does not fire on programmatic change", () => { cy.mount( @@ -393,12 +385,8 @@ describe("Component Behavior", () => { cy.get("#group1").invoke("prop", "expanded", true); - cy.get("@toggle").should("have.been.calledOnce"); - cy.get("@toggle").its("firstCall.args.0.detail").should("deep.include", { - expanded: true, - programmatic: true, - }); cy.get("#group1").should("have.prop", "expanded", true); + cy.get("@toggle").should("not.have.been.called"); }); it("preventDefault suppresses the toggle", () => { @@ -425,23 +413,6 @@ describe("Component Behavior", () => { .should("have.attr", "aria-expanded", "true"); }); - it("preventDefault suppresses programmatic toggle", () => { - cy.mount( - - - - - ); - - cy.get("#sn1").then($el => { - $el[0].addEventListener("item-toggle", (e: Event) => e.preventDefault()); - }); - - cy.get("#group1").invoke("prop", "expanded", true); - - cy.get("#group1").should("have.prop", "expanded", false); - }); - it("does not fire during initial rendering", () => { cy.mount( diff --git a/packages/fiori/src/SideNavigation.ts b/packages/fiori/src/SideNavigation.ts index 3405a3fa5bf81..b73bb7906f590 100644 --- a/packages/fiori/src/SideNavigation.ts +++ b/packages/fiori/src/SideNavigation.ts @@ -59,8 +59,6 @@ type SideNavigationItemClickEventDetail = { type SideNavigationItemToggleEventDetail = { item: SideNavigationItemBase, - expanded: boolean, - programmatic: boolean, }; type PopupSideNavigationItem = SideNavigationItem & { associatedItem: SideNavigationSelectableItemBase }; @@ -145,16 +143,14 @@ type PopupSideNavigationItem = SideNavigationItem & { associatedItem: SideNaviga }) /** - * Fired when a `ui5-side-navigation-item` or `ui5-side-navigation-group` is expanded or collapsed. + * Fired when a `ui5-side-navigation-item` or `ui5-side-navigation-group` is expanded or collapsed by user interaction. * * **Note:** You can call `preventDefault()` on the event to suppress the expand/collapse. * The `expanded` state stays unchanged. This is handy, for example, if you want to * dynamically load child items before allowing a parent item to expand. * * @param {SideNavigationItemBase} item The toggled item. - * @param {boolean} expanded The new (requested) expanded state of the item. - * @param {boolean} programmatic Indicates whether the change was triggered programmatically (`true`) or by user interaction (`false`). - * @since 2.25.0 + * @since 2.26.0 * @public */ @event("item-toggle", { diff --git a/packages/fiori/src/SideNavigationGroup.ts b/packages/fiori/src/SideNavigationGroup.ts index a1e66996dec5b..edc4e0566a2d3 100644 --- a/packages/fiori/src/SideNavigationGroup.ts +++ b/packages/fiori/src/SideNavigationGroup.ts @@ -58,17 +58,7 @@ class SideNavigationGroup extends SideNavigationItemBase { * @default false */ @property({ type: Boolean }) - get expanded(): boolean { - return this._expandedState; - } - - set expanded(value: boolean) { - this._expandedState = toggleExpanded(this, value); - } - - _expandedState = false; - - _userToggle = false; + expanded = false; belowGroup = false; @@ -202,9 +192,7 @@ class SideNavigationGroup extends SideNavigationItemBase { } _setExpandedByUser(value: boolean) { - this._userToggle = true; - this.expanded = value; - this._userToggle = false; + toggleExpanded(this, value); } get isSideNavigationGroup() { diff --git a/packages/fiori/src/SideNavigationItem.ts b/packages/fiori/src/SideNavigationItem.ts index feee121544166..87b0202c7c9c6 100644 --- a/packages/fiori/src/SideNavigationItem.ts +++ b/packages/fiori/src/SideNavigationItem.ts @@ -63,17 +63,7 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { * @default false */ @property({ type: Boolean }) - get expanded(): boolean { - return this._expandedState; - } - - set expanded(value: boolean) { - this._expandedState = toggleExpanded(this, value); - } - - _expandedState = false; - - _userToggle = false; + expanded = false; /** * Defines if the item should be collapsible or not. @@ -397,9 +387,7 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { } _setExpandedByUser(value: boolean) { - this._userToggle = true; - this.expanded = value; - this._userToggle = false; + toggleExpanded(this, value); } get isSideNavigationItem() { diff --git a/packages/fiori/src/SideNavigationItemBase.ts b/packages/fiori/src/SideNavigationItemBase.ts index 4af5266ae7321..8175decf18723 100644 --- a/packages/fiori/src/SideNavigationItemBase.ts +++ b/packages/fiori/src/SideNavigationItemBase.ts @@ -175,46 +175,34 @@ export type { export const isInstanceOfSideNavigationItemBase = createInstanceChecker("isSideNavigationItemBase"); type ExpandableItem = SideNavigationItemBase & { - _expandedState: boolean; - _userToggle: boolean; + expanded: boolean; }; /** - * Shared logic for the `expanded` property setter of the expandable items + * Shared logic for the user-driven expand/collapse of the expandable items * (`SideNavigationGroup` and `SideNavigationItem`). * - * Fires the cancelable `item-toggle` event on the parent `ui5-side-navigation` and - * returns the effective value to be stored. When the event is prevented, the old value - * is kept (the toggle is suppressed). The `_userToggle` flag on the item distinguishes - * user interaction (`programmatic: false`) from programmatic assignments (`programmatic: true`). + * Fires the cancelable `item-toggle` event on the parent `ui5-side-navigation` and, + * unless the event is prevented, applies the new `expanded` value. The event is only + * fired for user interaction - programmatic changes to `expanded` stay silent. * * @private */ -const toggleExpanded = (item: ExpandableItem, value: boolean): boolean => { - if (item._expandedState === value) { - return item._expandedState; +const toggleExpanded = (item: ExpandableItem, value: boolean): void => { + if (item.expanded === value) { + return; } const sideNav = item.sideNavigation; - // Before the item is wired to a parent side navigation there is nothing to fire the - // event on - just store the value silently (e.g. when set before insertion in the DOM). - if (!sideNav) { - return value; - } - - const executeEvent = sideNav.fireDecoratorEvent("item-toggle", { + const executeToggle = sideNav ? sideNav.fireDecoratorEvent("item-toggle", { item, - expanded: value, - programmatic: !item._userToggle, - }); + }) : true; // The event was prevented - keep the old value, suppressing the toggle. - if (!executeEvent) { - return item._expandedState; + if (executeToggle) { + item.expanded = value; } - - return value; }; export { diff --git a/packages/fiori/test/pages/SideNavigationWithGroups.html b/packages/fiori/test/pages/SideNavigationWithGroups.html index 2c833d513a34a..082060642f4ab 100644 --- a/packages/fiori/test/pages/SideNavigationWithGroups.html +++ b/packages/fiori/test/pages/SideNavigationWithGroups.html @@ -19,7 +19,7 @@ icon="home" href="#home" tooltip="Home 1 tooltip">
- + @@ -110,8 +110,6 @@ Change width
- Toggle "Group" programmatically - Toggle "People" item programmatically
item-toggle log @@ -146,12 +144,11 @@ const preventToggle = document.getElementById("preventToggle"); sn.addEventListener("item-toggle", e => { - const { item, expanded, programmatic } = e.detail; + const { item } = e.detail; const label = item.text || "(no text)"; toggleLog.textContent = - `${expanded ? "expand" : "collapse"} "${label}" ` - + `[${programmatic ? "programmatic" : "user"}]` + `toggle "${label}"` + `${preventToggle.checked ? " - prevented" : ""}\n` + toggleLog.textContent; @@ -159,18 +156,6 @@ e.preventDefault(); } }); - - // Toggle the first group programmatically (fires item-toggle with programmatic: true) - document.getElementById("toggleGroup1").addEventListener("click", () => { - const group1 = document.getElementById("group1"); - group1.expanded = !group1.expanded; - }); - - // Toggle the "People" item programmatically (fires item-toggle with programmatic: true) - document.getElementById("toggleItem").addEventListener("click", () => { - const peopleItem = document.getElementById("peopleItem"); - peopleItem.expanded = !peopleItem.expanded; - }); From 6b26b892069599d819a3beeee8c2f51524724b98 Mon Sep 17 00:00:00 2001 From: Lidiya Georgieva Date: Wed, 29 Jul 2026 18:05:51 +0300 Subject: [PATCH 3/4] chore: address code review comments --- packages/fiori/src/SideNavigation.ts | 23 ++++++++++++- packages/fiori/src/SideNavigationGroup.ts | 4 +-- packages/fiori/src/SideNavigationItem.ts | 3 +- packages/fiori/src/SideNavigationItemBase.ts | 35 -------------------- 4 files changed, 25 insertions(+), 40 deletions(-) diff --git a/packages/fiori/src/SideNavigation.ts b/packages/fiori/src/SideNavigation.ts index b73bb7906f590..6f4a5a0a5213f 100644 --- a/packages/fiori/src/SideNavigation.ts +++ b/packages/fiori/src/SideNavigation.ts @@ -143,7 +143,7 @@ type PopupSideNavigationItem = SideNavigationItem & { associatedItem: SideNaviga }) /** - * Fired when a `ui5-side-navigation-item` or `ui5-side-navigation-group` is expanded or collapsed by user interaction. + * Fired when a `ui5-side-navigation-item` or `ui5-side-navigation-group` is expanded or collapsed. * * **Note:** You can call `preventDefault()` on the event to suppress the expand/collapse. * The `expanded` state stays unchanged. This is handy, for example, if you want to @@ -827,6 +827,27 @@ class SideNavigation extends UI5Element { item.selected = true; } + /** + * Handles the user-driven expand/collapse of an expandable item + * (`SideNavigationGroup` or `SideNavigationItem`). + * + * Fires the cancelable `item-toggle` event and, unless it is prevented, applies the + * new `expanded` value. The event is only fired for user interaction - programmatic + * changes to `expanded` stay silent. + * + * @private + */ + _toggleItem(item: SideNavigationItemBase & { expanded: boolean }, value: boolean) { + if (item.expanded === value) { + return; + } + + // The event was prevented - keep the old value, suppressing the toggle. + if (this.fireDecoratorEvent("item-toggle", { item })) { + item.expanded = value; + } + } + get _overflowItem() { const overflowItem = this.shadowRoot!.querySelector(".ui5-sn-item-overflow"); if (overflowItem) { diff --git a/packages/fiori/src/SideNavigationGroup.ts b/packages/fiori/src/SideNavigationGroup.ts index edc4e0566a2d3..1ae15aa88d627 100644 --- a/packages/fiori/src/SideNavigationGroup.ts +++ b/packages/fiori/src/SideNavigationGroup.ts @@ -11,7 +11,7 @@ import { } from "@ui5/webcomponents-base/dist/Keys.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; -import SideNavigationItemBase, { toggleExpanded } from "./SideNavigationItemBase.js"; +import SideNavigationItemBase from "./SideNavigationItemBase.js"; import type SideNavigationSelectableItemBase from "./SideNavigationSelectableItemBase.js"; import type SideNavigationItem from "./SideNavigationItem.js"; import SideNavigationGroupTemplate from "./SideNavigationGroupTemplate.js"; @@ -192,7 +192,7 @@ class SideNavigationGroup extends SideNavigationItemBase { } _setExpandedByUser(value: boolean) { - toggleExpanded(this, value); + this.sideNavigation?._toggleItem(this, value); } get isSideNavigationGroup() { diff --git a/packages/fiori/src/SideNavigationItem.ts b/packages/fiori/src/SideNavigationItem.ts index 87b0202c7c9c6..892d3981d4930 100644 --- a/packages/fiori/src/SideNavigationItem.ts +++ b/packages/fiori/src/SideNavigationItem.ts @@ -12,7 +12,6 @@ import { isEnter, } from "@ui5/webcomponents-base/dist/Keys.js"; import type SideNavigationItemBase from "./SideNavigationItemBase.js"; -import { toggleExpanded } from "./SideNavigationItemBase.js"; import SideNavigationSelectableItemBase from "./SideNavigationSelectableItemBase.js"; import type SideNavigationSubItem from "./SideNavigationSubItem.js"; import { @@ -387,7 +386,7 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { } _setExpandedByUser(value: boolean) { - toggleExpanded(this, value); + this.sideNavigation?._toggleItem(this, value); } get isSideNavigationItem() { diff --git a/packages/fiori/src/SideNavigationItemBase.ts b/packages/fiori/src/SideNavigationItemBase.ts index 8175decf18723..466bd786cd8ac 100644 --- a/packages/fiori/src/SideNavigationItemBase.ts +++ b/packages/fiori/src/SideNavigationItemBase.ts @@ -173,38 +173,3 @@ export type { SideNavigationItemClickEventDetail, }; export const isInstanceOfSideNavigationItemBase = createInstanceChecker("isSideNavigationItemBase"); - -type ExpandableItem = SideNavigationItemBase & { - expanded: boolean; -}; - -/** - * Shared logic for the user-driven expand/collapse of the expandable items - * (`SideNavigationGroup` and `SideNavigationItem`). - * - * Fires the cancelable `item-toggle` event on the parent `ui5-side-navigation` and, - * unless the event is prevented, applies the new `expanded` value. The event is only - * fired for user interaction - programmatic changes to `expanded` stay silent. - * - * @private - */ -const toggleExpanded = (item: ExpandableItem, value: boolean): void => { - if (item.expanded === value) { - return; - } - - const sideNav = item.sideNavigation; - - const executeToggle = sideNav ? sideNav.fireDecoratorEvent("item-toggle", { - item, - }) : true; - - // The event was prevented - keep the old value, suppressing the toggle. - if (executeToggle) { - item.expanded = value; - } -}; - -export { - toggleExpanded, -}; From 31cdc80a9063cd42b2ff71143e3b7fd76471281a Mon Sep 17 00:00:00 2001 From: Lidiya Georgieva Date: Thu, 30 Jul 2026 17:22:44 +0300 Subject: [PATCH 4/4] chore: refine --- packages/fiori/src/SideNavigation.ts | 21 -------------- packages/fiori/src/SideNavigationGroup.ts | 32 +++++++++++++-------- packages/fiori/src/SideNavigationItem.ts | 34 +++++++++++++++-------- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/packages/fiori/src/SideNavigation.ts b/packages/fiori/src/SideNavigation.ts index 6f4a5a0a5213f..56c2c82ce824c 100644 --- a/packages/fiori/src/SideNavigation.ts +++ b/packages/fiori/src/SideNavigation.ts @@ -827,27 +827,6 @@ class SideNavigation extends UI5Element { item.selected = true; } - /** - * Handles the user-driven expand/collapse of an expandable item - * (`SideNavigationGroup` or `SideNavigationItem`). - * - * Fires the cancelable `item-toggle` event and, unless it is prevented, applies the - * new `expanded` value. The event is only fired for user interaction - programmatic - * changes to `expanded` stay silent. - * - * @private - */ - _toggleItem(item: SideNavigationItemBase & { expanded: boolean }, value: boolean) { - if (item.expanded === value) { - return; - } - - // The event was prevented - keep the old value, suppressing the toggle. - if (this.fireDecoratorEvent("item-toggle", { item })) { - item.expanded = value; - } - } - get _overflowItem() { const overflowItem = this.shadowRoot!.querySelector(".ui5-sn-item-overflow"); if (overflowItem) { diff --git a/packages/fiori/src/SideNavigationGroup.ts b/packages/fiori/src/SideNavigationGroup.ts index 1ae15aa88d627..974769d060c5a 100644 --- a/packages/fiori/src/SideNavigationGroup.ts +++ b/packages/fiori/src/SideNavigationGroup.ts @@ -154,29 +154,29 @@ class SideNavigationGroup extends SideNavigationItemBase { if (isLeft(e)) { e.preventDefault(); - this._setExpandedByUser(isRTL); + this._toggle(isRTL); return; } if (isRight(e)) { e.preventDefault(); - this._setExpandedByUser(!isRTL); + this._toggle(!isRTL); } if (isMinus(e)) { e.preventDefault(); - this._setExpandedByUser(false); + this._toggle(false); return; } if (isPlus(e)) { e.preventDefault(); - this._setExpandedByUser(true); + this._toggle(true); } } _onclick() { - this._toggle(); + this._toggle(!this.expanded); } _onfocusin(e: FocusEvent) { @@ -185,14 +185,24 @@ class SideNavigationGroup extends SideNavigationItemBase { this.sideNavigation?.focusItem(this); } - _toggle() { - if (!this.disabled) { - this._setExpandedByUser(!this.expanded); + /** + * Handles the user-driven expand/collapse of the group. + * + * Fires the cancelable `item-toggle` event and, unless it is prevented, applies the + * new `expanded` value. The event is only fired for user interaction - programmatic + * changes to `expanded` stay silent. + * + * @private + */ + _toggle(expanded: boolean) { + if (this.disabled || this.expanded === expanded) { + return; } - } - _setExpandedByUser(value: boolean) { - this.sideNavigation?._toggleItem(this, value); + // The event was prevented - keep the old value, suppressing the toggle. + if (this.sideNavigation?.fireDecoratorEvent("item-toggle", { item: this })) { + this.expanded = expanded; + } } get isSideNavigationGroup() { diff --git a/packages/fiori/src/SideNavigationItem.ts b/packages/fiori/src/SideNavigationItem.ts index 892d3981d4930..078b11afe063e 100644 --- a/packages/fiori/src/SideNavigationItem.ts +++ b/packages/fiori/src/SideNavigationItem.ts @@ -287,7 +287,7 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { _onToggleClick(e: CustomEvent) { e.stopPropagation(); - this._toggle(); + this._toggle(!this.expanded); } _onkeydown(e: KeyboardEvent) { @@ -304,25 +304,25 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { if (isLeft(e)) { e.preventDefault(); - this._setExpandedByUser(isRTL); + this._toggle(isRTL); return; } if (isRight(e)) { e.preventDefault(); - this._setExpandedByUser(!isRTL); + this._toggle(!isRTL); return; } if (isMinus(e)) { e.preventDefault(); - this._setExpandedByUser(false); + this._toggle(false); return; } if (isPlus(e)) { e.preventDefault(); - this._setExpandedByUser(true); + this._toggle(true); return; } @@ -349,7 +349,7 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { _onclick(e: MouseEvent) { if (!this.inPopover && this.unselectable) { - this._toggle(); + this._toggle(!this.expanded); } super._onclick(e); @@ -379,14 +379,24 @@ class SideNavigationItem extends SideNavigationSelectableItemBase { this.getDomRef()!.classList.add("ui5-sn-item-no-hover-effect"); } - _toggle() { - if (this.items.length && !this.effectiveDisabled) { - this._setExpandedByUser(!this.expanded); + /** + * Handles the user-driven expand/collapse of the item. + * + * Fires the cancelable `item-toggle` event and, unless it is prevented, applies the + * new `expanded` value. The event is only fired for user interaction - programmatic + * changes to `expanded` stay silent. + * + * @private + */ + _toggle(expanded: boolean) { + if (!this.items.length || this.effectiveDisabled || this.expanded === expanded) { + return; } - } - _setExpandedByUser(value: boolean) { - this.sideNavigation?._toggleItem(this, value); + // The event was prevented - keep the old value, suppressing the toggle. + if (this.sideNavigation?.fireDecoratorEvent("item-toggle", { item: this })) { + this.expanded = expanded; + } } get isSideNavigationItem() {