diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx
index 9d13c0f3ef063..51cea3570df71 100644
--- a/packages/main/cypress/specs/Toolbar.cy.tsx
+++ b/packages/main/cypress/specs/Toolbar.cy.tsx
@@ -5,6 +5,7 @@ import ToolbarSelectOption from "../../src/ToolbarSelectOption.js";
import ToolbarSeparator from "../../src/ToolbarSeparator.js";
import ToolbarSpacer from "../../src/ToolbarSpacer.js";
import ToolbarItem from "../../src/ToolbarItem.js";
+import type ToolbarItemBase from "../../src/ToolbarItemBase.js";
import CheckBox from "../../src/CheckBox.js";
import add from "@ui5/webcomponents-icons/dist/add.js";
import decline from "@ui5/webcomponents-icons/dist/decline.js";
@@ -1315,3 +1316,450 @@ describe("Toolbar overflow button accessible name", () => {
.should("have.attr", "accessible-name", "More actions for Opportunity 123");
});
});
+
+describe("Toolbar overflow group", () => {
+ it("overflows both members of a contiguous group together when only one would otherwise overflow", () => {
+ // Four buttons + one container width chosen so that without grouping
+ // the rightmost button (and only the rightmost) would overflow. With both
+ // "GroupA"/"GroupB" tagged into the same group, both must overflow together.
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Both grouped items must overflow together. At this container width an
+ // ungrouped clone would overflow exactly one item (the rightmost one);
+ // with the group present, BOTH must move into the popover.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='Solo1']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='Solo2']")
+ .should("have.prop", "isOverflowed", false);
+ });
+
+ it("keeps an ungrouped item between non-contiguous group members in the bar while both group members overflow", () => {
+ // Source order: [GroupA(g), Solo2(ungrouped), Solo3(ungrouped), GroupB(g)].
+ // With the chosen width, only the rightmost button would overflow naturally.
+ // With grouping, the entire group (A and B) goes to the popover and the
+ // ungrouped Solo2/Solo3 between them keep their slot positions in the bar.
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Group members both in popover.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+ // Ungrouped items between them stay in the visible bar.
+ cy.get("[ui5-toolbar-button][text='Solo2']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='Solo3']")
+ .should("have.prop", "isOverflowed", false);
+
+ // In the popover, group members appear adjacent and in slot order
+ // (GroupA before GroupB), regardless of where ungrouped items sit
+ // between them in the source.
+ cy.get("#otb_noncontiguous_group").then($tb => {
+ const tb = $tb[0] as Toolbar;
+ const order = tb.overflowItems.map(it => (it as ToolbarButton).text);
+ expect(order).to.deep.equal(["GroupA", "GroupB"]);
+ });
+ });
+
+ it("returns both group members to the bar when the toolbar widens enough to fit them again", () => {
+ // Start wide enough so the group fits, narrow so both group members
+ // overflow together, then widen again — both members must come back.
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // At 600px, both group members fit in the bar.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", false);
+
+ // Narrow the host — both group members must overflow together.
+ cy.get("#otb_resize_host").invoke("attr", "style", "width: 220px;");
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+
+ // Widen the host — both group members must return to the bar.
+ cy.get("#otb_resize_host").invoke("attr", "style", "width: 600px;");
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", false);
+ });
+
+ it("mirrors the popover order in reverse-overflow mode while keeping the group contiguous", () => {
+ // A two-member group + ungrouped items, narrow enough that the group overflows.
+ // With reverseOverflow=true (popover placed above the toolbar) the popover list
+ // is mirrored: [GroupA, GroupB] becomes [GroupB, GroupA], but the group stays
+ // adjacent.
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Flip reverseOverflow on and force a re-render via processOverflowLayout.
+ cy.get("#otb_reverse_group").then($tb => {
+ const tb = $tb[0] as Toolbar;
+ tb.reverseOverflow = true;
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(200);
+
+ // The popover items should appear in mirrored order: GroupB before GroupA.
+ cy.get("#otb_reverse_group").then($tb => {
+ const tb = $tb[0] as Toolbar;
+ const order = tb.overflowItems.map(it => (it as ToolbarButton).text);
+ expect(order).to.deep.equal(["GroupB", "GroupA"]);
+ });
+ });
+
+ it("re-distributes when overflowGroup changes on an item at runtime", () => {
+ // Start grouped: at this width both grouped items overflow together.
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Baseline: both group members overflow.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+
+ // Remove the group from GroupA at runtime. The pair is no longer yoked,
+ // and only the rightmost item should remain in the popover.
+ cy.get("[ui5-toolbar-button][text='GroupA']").then($el => {
+ ($el[0] as ToolbarItemBase).overflowGroup = "";
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+ });
+
+ it("preserves slot order in the visible bar regardless of grouping", () => {
+ // Layout in source order: [Solo1, GroupA(g), Solo2, Solo3, GroupB(g)].
+ // At the chosen width, the group (A+B) overflows together. The visible bar
+ // must keep Solo1, Solo2, Solo3 in that slot order — never reordered to put
+ // the group's "remaining" member next to its sibling.
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // The visible bar's standardItems (items not in overflow) must be in slot order.
+ cy.get("#otb_slot_order").then($tb => {
+ const tb = $tb[0] as Toolbar;
+ const barOrder = tb.standardItems.map(it => (it as ToolbarButton).text);
+ expect(barOrder).to.deep.equal(["Solo1", "Solo2", "Solo3"]);
+ });
+ });
+
+ it("pushes a whole group into overflow even when one member's worth of width would have sufficed (atomic over-shoot)", () => {
+ // Five buttons in source order: [Solo1, Solo2, GroupA, GroupB, GroupC] where
+ // GroupA/B/C share `overflow-group="g"`. At the chosen container width an
+ // ungrouped clone would overflow ONLY the rightmost button (GroupC) — that's
+ // the worth of width the overflow algorithm "needs" to recover. With the group
+ // in place, the entire group (≈3× one button's width) must move to the popover
+ // atomically. The over-shoot is accepted by design (ADR-0001).
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // All three group members go to overflow, not just the rightmost one.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupC']")
+ .should("have.prop", "isOverflowed", true);
+ // Solo items remain in the bar — extra empty space is the accepted cost.
+ cy.get("[ui5-toolbar-button][text='Solo1']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='Solo2']")
+ .should("have.prop", "isOverflowed", false);
+ });
+
+ it("warns once for AlwaysOverflow on a grouped item and treats its priority as Default for the layout pass", () => {
+ // GroupA has both `overflow-group="g"` AND `overflowPriority="AlwaysOverflow"`.
+ // ADR-0001 forbids this combination: the warning fires once, the priority is
+ // dropped to `Default` for the layout pass, and the group's atomic-overflow
+ // contract is preserved (GroupA and GroupB go together — decided by space,
+ // not by the now-ignored absolute priority).
+ cy.window().then(win => {
+ cy.stub(win.console, "warn").as("warn");
+ });
+
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // At 600px both group members fit in the bar — the AlwaysOverflow priority
+ // would otherwise force GroupA into the popover; under the validation rule
+ // it is dropped to Default and the whole group stays atomically in the bar.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", false);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", false);
+
+ // Force another layout pass — the warning must NOT re-fire.
+ cy.get("#otb_priority_violation_always").then($tb => {
+ ($tb[0] as Toolbar).onResize();
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(200);
+
+ // Exactly one warning, naming the offending element and the rule.
+ cy.get("@warn").should("have.been.calledOnce");
+ cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i);
+ cy.get("@warn").its("firstCall.args.0").should("match", /AlwaysOverflow|priority/i);
+ });
+
+ it("warns once for NeverOverflow on a grouped item and treats its priority as Default for the layout pass", () => {
+ // GroupA has both `overflow-group="g"` AND `overflowPriority="NeverOverflow"`.
+ // Under the ADR-0001 rule, the warning fires once and the priority is dropped
+ // to `Default` for the layout pass — so when the toolbar is narrowed enough
+ // for the group to need overflow, GroupA can in fact overflow (alongside GroupB).
+ cy.window().then(win => {
+ cy.stub(win.console, "warn").as("warn");
+ });
+
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // With the priority dropped, both grouped items overflow atomically.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+
+ // Force another layout pass — the warning must NOT re-fire.
+ cy.get("#otb_priority_violation_never").then($tb => {
+ ($tb[0] as Toolbar).onResize();
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(200);
+
+ // Exactly one warning, naming the offending element and the rule.
+ cy.get("@warn").should("have.been.calledOnce");
+ cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i);
+ cy.get("@warn").its("firstCall.args.0").should("match", /NeverOverflow|priority/i);
+ });
+
+ it("warns once for a spacer with overflow-group and leaves the spacer's overflow behavior unchanged (not yoked to the group)", () => {
+ // A fixed-width spacer tagged with the same group as two buttons. The buttons
+ // overflow together; the spacer is NOT yoked — it stays in the visible bar.
+ // One spacer-rule warning fires once across re-renders.
+ cy.window().then(win => {
+ cy.stub(win.console, "warn").as("warn");
+ });
+
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Both grouped buttons overflow.
+ cy.get("[ui5-toolbar-button][text='GroupA']")
+ .should("have.prop", "isOverflowed", true);
+ cy.get("[ui5-toolbar-button][text='GroupB']")
+ .should("have.prop", "isOverflowed", true);
+
+ // The spacer is NOT yoked — it stays in the bar (its existing overflow
+ // behavior is unaffected by the group tag).
+ cy.get("#otb_spacer_violation").then($tb => {
+ const tb = $tb[0] as Toolbar;
+ const spacer = tb.items.find(it => it.isSpacer)!;
+ expect(spacer.isOverflowed, "spacer must not be yoked into the group's overflow").to.equal(false);
+ expect(tb.standardItems, "spacer must remain a standard (visible) item").to.include(spacer);
+ });
+
+ // Force another layout pass — the spacer warning must NOT re-fire.
+ cy.get("#otb_spacer_violation").then($tb => {
+ ($tb[0] as Toolbar).onResize();
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(200);
+
+ // Exactly one warning, naming the spacer and the rule.
+ cy.get("@warn").should("have.been.calledOnce");
+ cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i);
+ cy.get("@warn").its("firstCall.args.0").should("match", /spacer/i);
+ });
+
+ it("warns for the canonical flex-spacer case (no width, default priority) with a non-empty overflow-group", () => {
+ // The default `` has no width and `overflow-priority="Default"` —
+ // so it sits in `movableItems` like a normal item. Putting `overflow-group` on it must
+ // still trip the spacer-rule warning even though `ignoreSpace` removes it from the
+ // popover render and visible bar regardless of the group.
+ cy.window().then(win => {
+ cy.stub(win.console, "warn").as("warn");
+ });
+
+ cy.mount(
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ cy.get("@warn").should("have.been.calledOnce");
+ cy.get("@warn").its("firstCall.args.0").should("match", /spacer/i);
+ });
+
+ it("does not warn for valid configurations: grouped Default-priority items and ungrouped spacers", () => {
+ // All combinations here are valid by ADR-0001: two items share a non-empty
+ // group with the default priority, and a spacer carries no group tag.
+ // The toolbar must remain silent — no `console.warn` calls.
+ cy.window().then(win => {
+ cy.stub(win.console, "warn").as("warn");
+ });
+
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ // Trigger an extra layout pass for good measure.
+ cy.get("#otb_no_warnings").then($tb => {
+ ($tb[0] as Toolbar).onResize();
+ });
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(200);
+
+ cy.get("@warn").should("not.have.been.called");
+ });
+});
diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts
index 977fbab7554ed..3f238805e446f 100644
--- a/packages/main/src/Toolbar.ts
+++ b/packages/main/src/Toolbar.ts
@@ -46,6 +46,19 @@ type ToolbarMinWidthChangeEventDetail = {
minWidth: number,
};
+/**
+ * One step of the overflow distribution algorithm — either a single ungrouped item or
+ * all members of one non-empty `overflowGroup`, treated atomically. A unit's order key
+ * for the right-to-left distribution walk is the rightmost member's slot index; its
+ * width is the sum of member widths.
+ */
+type DistributionUnit = {
+ group: string,
+ members: Array,
+ width: number,
+ rightmostIndex: number,
+};
+
function calculateCSSREMValue(styleSet: CSSStyleDeclaration, propertyName: string): number {
return Number(styleSet.getPropertyValue(propertyName).replace("rem", "")) * parseInt(getComputedStyle(document.body).getPropertyValue("font-size"));
}
@@ -62,6 +75,14 @@ function parsePxValue(styleSet: CSSStyleDeclaration, propertyName: string): numb
* The `ui5-toolbar` component is used to create a horizontal layout with items.
* The items can be overflowing in a popover, when the space is not enough to show all of them.
*
+ * ### Grouped Overflow
+ *
+ * Items that share the same non-empty `overflowGroup` string are treated as one atomic
+ * unit during overflow distribution: when any member must move into the overflow
+ * popover, all members move together. The visible bar always preserves slot order;
+ * the group becomes adjacent only inside the popover. See the `overflowGroup` property
+ * on `ToolbarItemBase` for the full contract.
+ *
* ### Keyboard Handling
* The `ui5-toolbar` provides advanced keyboard handling.
*
@@ -192,6 +213,9 @@ class Toolbar extends UI5Element {
itemsToOverflow: Array = [];
itemsWidth = 0;
minContentWidth = 0;
+ // Snapshot of children's `overflowGroup` values, joined with "|". Tracks whether
+ // the grouping decision has changed even when total content width has not.
+ _groupingKey = "";
_lastFocusedItem?: ToolbarItemBase | HTMLElement;
ITEMS_WIDTH_MAP: Map = new Map();
@@ -223,11 +247,11 @@ class Toolbar extends UI5Element {
}
get alwaysOverflowItems() {
- return this.items.filter(item => item.overflowPriority === ToolbarItemOverflowBehavior.AlwaysOverflow);
+ return this.items.filter(item => item.effectiveOverflowPriority === ToolbarItemOverflowBehavior.AlwaysOverflow);
}
get movableItems() {
- return this.items.filter(item => item.overflowPriority !== ToolbarItemOverflowBehavior.AlwaysOverflow && item.overflowPriority !== ToolbarItemOverflowBehavior.NeverOverflow);
+ return this.items.filter(item => item.effectiveOverflowPriority !== ToolbarItemOverflowBehavior.AlwaysOverflow && item.effectiveOverflowPriority !== ToolbarItemOverflowBehavior.NeverOverflow);
}
get overflowItems() {
@@ -312,7 +336,8 @@ class Toolbar extends UI5Element {
onInvalidation(changeInfo: ChangeInfo) {
if (changeInfo.reason === "childchange") {
const currentItemsWidth = this.items.reduce((total, item) => total + this.getItemWidth(item), 0);
- if (currentItemsWidth !== this.itemsWidth) {
+ const currentGroupingKey = this.items.map(item => item.overflowGroup).join("|");
+ if (currentItemsWidth !== this.itemsWidth || currentGroupingKey !== this._groupingKey) {
this.onToolbarItemChange();
}
}
@@ -434,7 +459,7 @@ class Toolbar extends UI5Element {
this.items.forEach(item => {
const itemWidth = this.getItemWidth(item);
totalWidth += itemWidth;
- if (item.overflowPriority === ToolbarItemOverflowBehavior.NeverOverflow) {
+ if (item.effectiveOverflowPriority === ToolbarItemOverflowBehavior.NeverOverflow) {
minWidth += itemWidth;
}
this.ITEMS_WIDTH_MAP.set(item._id, itemWidth);
@@ -449,38 +474,115 @@ class Toolbar extends UI5Element {
this.itemsWidth = totalWidth;
this.minContentWidth = minWidth;
+ this._groupingKey = this.items.map(item => item.overflowGroup).join("|");
}
distributeItems(overflowSpace = 0) {
- const movableItems = this.movableItems.reverse();
- let index = 0;
- let currentItem = movableItems[index];
-
this.itemsToOverflow = [];
// distribute items that always overflow
this.distributeItemsThatAlwaysOverflow();
- while (overflowSpace > 0 && currentItem) {
- this.itemsToOverflow.unshift(currentItem);
- overflowSpace -= this.getCachedItemWidth(currentItem?._id) || 0;
- index++;
- currentItem = movableItems[index];
- }
-
- // If the last bar item is a spacer, force it to the overflow even if there is enough space for it
- if (index < movableItems.length) {
- let lastItem = movableItems[index];
- while (index <= movableItems.length - 1 && lastItem.isSeparator) {
- this.itemsToOverflow.unshift(lastItem);
- index++;
- lastItem = movableItems[index];
+ // Bucket movable items (in slot order) into distribution units.
+ // A unit is either a single ungrouped item, or a group of items
+ // sharing the same non-empty `overflowGroup`. A unit is atomic:
+ // when it is pushed into overflow, all its members move together.
+ // The unit's representative slot position is its rightmost member's
+ // index — that index is what orders the unit during distribution.
+ const units = this.buildDistributionUnits();
+
+ // Walk units from rightmost to leftmost, pushing each atomically.
+ // A unit is pushed in full as soon as overflowSpace is still positive;
+ // the post-push budget is allowed to go negative — over-shoot is accepted
+ // by design (ADR-0001 §Consequences) because a group is indivisible.
+ const overflowedItems: Array = [];
+ let nextNonOverflowedUnitIndex = units.length - 1;
+ for (let i = units.length - 1; i >= 0; i--) {
+ if (overflowSpace <= 0) {
+ nextNonOverflowedUnitIndex = i;
+ break;
+ }
+ const unit = units[i];
+ overflowedItems.push(...unit.members);
+ overflowSpace -= unit.width;
+ nextNonOverflowedUnitIndex = i - 1;
+ }
+
+ // If the last bar item is a separator, force it (and any contiguous
+ // trailing separators) into overflow even if there is enough space.
+ // Only single-member separator units are considered — pushing a
+ // group's entire content (non-separator content included) because its
+ // rightmost member happens to be a separator would be wrong.
+ while (nextNonOverflowedUnitIndex >= 0) {
+ const unit = units[nextNonOverflowedUnitIndex];
+ if (unit.members.length === 1 && unit.members[0].isSeparator) {
+ overflowedItems.push(...unit.members);
+ nextNonOverflowedUnitIndex--;
+ } else {
+ break;
}
}
+ // itemsToOverflow must be in slot order so popover rendering matches
+ // the developer's source order (group members adjacent by construction).
+ const slotIndex = new Map();
+ this.items.forEach((item, idx) => slotIndex.set(item, idx));
+ overflowedItems.sort((a, b) => (slotIndex.get(a)! - slotIndex.get(b)!));
+ this.itemsToOverflow.push(...overflowedItems);
+
this.setSeperatorsVisibilityInOverflow();
}
+ /**
+ * Buckets `movableItems` (in slot order) into atomic distribution units.
+ * Each unit either holds a single ungrouped item or all members of one
+ * non-empty `overflowGroup`. A unit's order key is its rightmost member's
+ * slot index. Returned units are sorted ascending by that key.
+ */
+ buildDistributionUnits(): Array {
+ const movable = this.movableItems;
+ const slotIndex = new Map();
+ this.items.forEach((item, idx) => slotIndex.set(item, idx));
+
+ const groupUnits = new Map();
+ const units: Array = [];
+
+ movable.forEach(item => {
+ const itemWidth = this.getCachedItemWidth(item._id) || 0;
+ const slotIdx = slotIndex.get(item)!;
+ const groupKey = item.effectiveOverflowGroup;
+ if (groupKey === "") {
+ units.push({
+ group: "",
+ members: [item],
+ width: itemWidth,
+ rightmostIndex: slotIdx,
+ });
+ return;
+ }
+ const existing = groupUnits.get(groupKey);
+ if (existing) {
+ existing.members.push(item);
+ existing.width += itemWidth;
+ if (slotIdx > existing.rightmostIndex) {
+ existing.rightmostIndex = slotIdx;
+ }
+ } else {
+ const unit: DistributionUnit = {
+ group: groupKey,
+ members: [item],
+ width: itemWidth,
+ rightmostIndex: slotIdx,
+ };
+ groupUnits.set(groupKey, unit);
+ units.push(unit);
+ }
+ });
+
+ units.sort((a, b) => a.rightmostIndex - b.rightmostIndex);
+ return units;
+ }
+
distributeItemsThatAlwaysOverflow() {
this.alwaysOverflowItems.forEach((item: ToolbarItemBase) => {
this.itemsToOverflow.push(item);
diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts
index 989819ad242eb..74f80fccb8999 100644
--- a/packages/main/src/ToolbarItemBase.ts
+++ b/packages/main/src/ToolbarItemBase.ts
@@ -40,6 +40,32 @@ class ToolbarItemBase extends UI5Element {
@property()
overflowPriority: `${ToolbarItemOverflowBehavior}` = "Default";
+ /**
+ * Co-overflow tag. Items in the same `ui5-toolbar` whose `overflowGroup` is the same
+ * non-empty string overflow as one atomic unit: either all visible in the bar, or all
+ * in the overflow popover, never split. The empty string (the default) means "no group" —
+ * the item participates in overflow independently.
+ *
+ * The tag is a free-form, case-sensitive string label (e.g. `"filters"`, `"search"`). It is
+ * layout-only and carries no ARIA, keyboard, or visual-cluster semantics. Items in a
+ * non-empty group must have `overflowPriority = "Default"`; `AlwaysOverflow` and
+ * `NeverOverflow` are forbidden inside a group — setting one of those on a grouped item
+ * emits a one-shot `console.warn` and the item's priority is treated as `Default` for
+ * the layout pass. Spacers (`ui5-toolbar-spacer`) do not participate in grouping; setting
+ * a non-empty `overflowGroup` on a spacer emits a one-shot `console.warn` and the spacer's
+ * existing overflow behavior is unchanged.
+ *
+ * The visible bar always preserves slot order — ungrouped items between group members
+ * keep their slot positions and the toolbar never reorders DOM children. In the popover
+ * group members appear adjacent in slot order.
+ *
+ * @public
+ * @default ""
+ * @since 2.27.0
+ */
+ @property()
+ overflowGroup = "";
+
/**
* Defines if the toolbar overflow popup should close upon interaction with the item.
* It will close by default.
@@ -70,6 +96,11 @@ class ToolbarItemBase extends UI5Element {
_isOverflowed: boolean = false;
+ // One-shot guards for `overflowGroup` validation warnings — suppress repeat
+ // warnings across re-renders, consistent with `ToolbarItem.checkForWrapper`.
+ _overflowGroupPriorityWarned = false;
+ _overflowGroupSpacerWarned = false;
+
get isOverflowed(): boolean {
return this._isOverflowed;
}
@@ -151,6 +182,63 @@ class ToolbarItemBase extends UI5Element {
return false;
}
+ /**
+ * Returns the `overflowPriority` actually used by the toolbar's distribution
+ * algorithm. Items in a non-empty `overflowGroup` must have `Default` priority
+ * (ADR-0001); when a developer puts `AlwaysOverflow` or `NeverOverflow` on a
+ * grouped non-spacer item, this getter emits a one-shot `console.warn` and
+ * downgrades the priority to `"Default"` for layout. Spacers are exempt
+ * from the priority-violation rule — they get their own spacer-rule warning
+ * elsewhere and keep their declared priority here.
+ *
+ * @protected
+ */
+ get effectiveOverflowPriority(): `${ToolbarItemOverflowBehavior}` {
+ const declared = this.overflowPriority;
+ if (
+ !this.isSpacer
+ && this.overflowGroup !== ""
+ && (declared === "AlwaysOverflow" || declared === "NeverOverflow")
+ ) {
+ if (!this._overflowGroupPriorityWarned) {
+ this._overflowGroupPriorityWarned = true;
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[ui5-toolbar] ${this.tagName.toLowerCase()} has both overflow-group="${this.overflowGroup}" and overflow-priority="${declared}". `
+ + `Items in a non-empty overflow-group must use overflow-priority="Default"; priority dropped to Default for layout.`,
+ this,
+ );
+ }
+ return "Default";
+ }
+ return declared;
+ }
+
+ /**
+ * Returns the `overflowGroup` actually used by the toolbar's distribution
+ * algorithm. Spacers cannot participate in grouping (ADR-0001); a spacer
+ * with a non-empty `overflowGroup` emits a one-shot `console.warn` and this
+ * getter returns `""` so the spacer is treated as ungrouped by the algorithm.
+ *
+ * @protected
+ */
+ get effectiveOverflowGroup(): string {
+ const declared = this.overflowGroup;
+ if (this.isSpacer && declared !== "") {
+ if (!this._overflowGroupSpacerWarned) {
+ this._overflowGroupSpacerWarned = true;
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[ui5-toolbar] ${this.tagName.toLowerCase()} has overflow-group="${declared}". `
+ + `Spacers cannot participate in an overflow-group; the group tag is ignored.`,
+ this,
+ );
+ }
+ return "";
+ }
+ return declared;
+ }
+
get stableDomRef() {
return this.getAttribute("stable-dom-ref") || `${this._id}-stable-dom-ref`;
}
diff --git a/packages/main/test/pages/ToolbarOverflowGroup.html b/packages/main/test/pages/ToolbarOverflowGroup.html
new file mode 100644
index 0000000000000..a31f40d265e06
--- /dev/null
+++ b/packages/main/test/pages/ToolbarOverflowGroup.html
@@ -0,0 +1,148 @@
+
+
+
+
+ Toolbar — overflow group
+
+
+
+
+
+
+
+
Toolbar — grouped overflow
+
+ Items that share the same non-empty overflow-group attribute overflow
+ as one atomic unit. Drag the slider to narrow the toolbar and watch the group
+ yoke into the overflow popover together.
+
+
+
+
1. Contiguous group: "filters"
+
The Filter label and its select share overflow-group="filters". They
+ are slotted next to each other and always overflow together.
+
+
+ 800px
+
+
+
+
+
+
+
+
+
+ All
+ Open
+ Closed
+
+
+
+
+
+
+
2. Non-contiguous group
+
Group members need not be adjacent. Here "Range" and "Date" share
+ overflow-group="range" with an unrelated "Sort" button between them.
+ The visible bar always preserves slot order; in the popover the group becomes
+ adjacent.
+
+
+ 800px
+
+
+
+
+
+
+
+
+
+
+
+
+
3. Runtime regrouping
+
Toggle the group on/off at runtime. The toolbar re-distributes on the next
+ render cycle.
+
+ Disable group "g"
+
+
+
+
+
+
+
+
+
+
+
+
+
4. Reverse-overflow placement
+
When the overflow popover is placed above the toolbar (reverseOverflow
+ mode), the list is mirrored end-to-end. The group remains contiguous, with its
+ internal order reversed alongside everything else.
+ Each scenario configures the toolbar incorrectly per ADR-0001 and should produce
+ exactly oneconsole.warn per offending item. Open the
+ DevTools console before interacting with the page.
+
+
+
+
1. Mixed-priority violation: AlwaysOverflow on a grouped item
+
+ Filter button has overflow-group="filters" AND
+ overflow-priority="AlwaysOverflow". The warning should fire once and
+ the item's priority should drop to Default for layout, so the group
+ overflows atomically together with the select.
+
+
+
+
+
+
+
+ All
+ Open
+ Closed
+
+
+
+
+ Shrink frame1 to 300px
+ Force re-render (toggle width)
+
+
+
+
+
2. Mixed-priority violation: NeverOverflow on a grouped item
+
+ Range button has overflow-group="range" AND
+ overflow-priority="NeverOverflow". The warning should fire once and the
+ priority should drop to Default so the group can overflow as one.
+
+
+
+
+
+
+
+
+
+
+ Shrink frame2 to 280px
+ Force re-render (toggle width)
+
+
+
+
+
3. Spacer violation: overflow-group on a spacer
+
+ A ui5-toolbar-spacer with overflow-group="sg". The warning
+ should fire once; the spacer continues its existing flex/spacer behavior and is
+ not yoked to the group's overflow decision.
+
+
+
+
+
+
+
+
+
+ Force re-render (toggle width)
+
+
+
+
+
4. Valid configuration (control case — no warnings expected)
+
+ Two grouped buttons with default priority and a spacer with no group. The console
+ should remain silent for this section.
+
+
+
+
+
+
+
+
+
+
+
+
+
Diagnostics
+
+ Click below to dump the console.warn count + the effective getters for
+ each test element. Useful when running this page under Chrome DevTools MCP.
+
+ Dump diagnostics
+
+
+
+
+
+
+
diff --git a/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx b/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx
index dd2cd194baa18..e19737bd25b70 100644
--- a/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx
+++ b/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx
@@ -8,6 +8,7 @@ import NeverOverflowingItems from "../../../_samples/main/Toolbar/NeverOverflowi
import SpacerAndSeparator from "../../../_samples/main/Toolbar/SpacerAndSeparator/SpacerAndSeparator.md";
import ItemsAlignment from "../../../_samples/main/Toolbar/ItemsAlignment/ItemsAlignment.md";
import ToolbarItem from "../../../_samples/main/Toolbar/ToolbarItem/ToolbarItem.md";
+import GroupedOverflow from "../../../_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md";
<%COMPONENT_OVERVIEW%>
@@ -39,3 +40,7 @@ You can align items to the Start, or to the End via the "align-content" property
### ToolbarItem
ToolbarItem wrapper used to add any component to Toolbar
+
+### Grouped Overflow
+Items that share the same non-empty `overflow-group` string overflow together as one atomic unit — either all visible in the bar or all in the overflow popover, never split. The value is a free-form string label.
+
diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md
new file mode 100644
index 0000000000000..0c062a836e844
--- /dev/null
+++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md
@@ -0,0 +1,5 @@
+import html from '!!raw-loader!./sample.html';
+import js from '!!raw-loader!./main.js';
+import react from '!!raw-loader!./sample.tsx';
+
+
diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js
new file mode 100644
index 0000000000000..ccb41cff3109b
--- /dev/null
+++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js
@@ -0,0 +1,7 @@
+import "@ui5/webcomponents/dist/Toolbar.js";
+import "@ui5/webcomponents/dist/ToolbarButton.js";
+import "@ui5/webcomponents/dist/ToolbarSelect.js";
+import "@ui5/webcomponents/dist/ToolbarSelectOption.js";
+import "@ui5/webcomponents/dist/ToolbarSeparator.js";
+import "@ui5/webcomponents-icons/dist/add.js";
+import "@ui5/webcomponents-icons/dist/decline.js";
diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html
new file mode 100644
index 0000000000000..fe93ecc0a7719
--- /dev/null
+++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ Sample
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Open
+ Closed
+
+
+
+
+
+
+
diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx
new file mode 100644
index 0000000000000..0bfaa1d83d5af
--- /dev/null
+++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx
@@ -0,0 +1,46 @@
+import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js";
+import ToolbarClass from "@ui5/webcomponents/dist/Toolbar.js";
+import ToolbarButtonClass from "@ui5/webcomponents/dist/ToolbarButton.js";
+import ToolbarSelectClass from "@ui5/webcomponents/dist/ToolbarSelect.js";
+import ToolbarSelectOptionClass from "@ui5/webcomponents/dist/ToolbarSelectOption.js";
+import ToolbarSeparatorClass from "@ui5/webcomponents/dist/ToolbarSeparator.js";
+import "@ui5/webcomponents-icons/dist/add.js";
+import "@ui5/webcomponents-icons/dist/decline.js";
+
+const Toolbar = createReactComponent(ToolbarClass);
+const ToolbarButton = createReactComponent(ToolbarButtonClass);
+const ToolbarSelect = createReactComponent(ToolbarSelectClass);
+const ToolbarSelectOption = createReactComponent(ToolbarSelectOptionClass);
+const ToolbarSeparator = createReactComponent(ToolbarSeparatorClass);
+
+function App() {
+ return (
+ <>
+ {/*
+ Items that share the same non-empty "overflowGroup" string overflow together
+ as one atomic unit — either all visible in the bar or all in the overflow
+ popover, never split. The value is a free-form string label; equality is
+ exact and case-sensitive.
+
+ Here the "Filter:" label-button and its select share overflowGroup="filters".
+ The toolbar's width is constrained so that the group cannot fit alongside the
+ ungrouped Add/Reject buttons — the bar shows Add, Reject, and the overflow
+ button. Click the overflow button to see both group members appear adjacent
+ to each other inside the popover.
+ */}
+
+
+
+
+
+
+ All
+ Open
+ Closed
+
+
+ >
+ );
+}
+
+export default App;